version-history-widget 0.1.0
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/LICENSE +21 -0
- package/README.md +85 -0
- package/middleware.js +24 -0
- package/package.json +29 -0
- package/serve.js +14 -0
- package/singlefile.js +34 -0
- package/vh.js +119 -0
- package/widget.js +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bimanshu
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# version-history-widget
|
|
2
|
+
|
|
3
|
+
Zero-dependency version history for web projects. Every change gets a titled,
|
|
4
|
+
restorable snapshot, and a floating "Versions" widget on the live site lets
|
|
5
|
+
anyone browse, search, and restore with one click.
|
|
6
|
+
|
|
7
|
+
Two modes:
|
|
8
|
+
|
|
9
|
+
- **Project mode** — multi-file sites/apps. Snapshots live in `.versions/`.
|
|
10
|
+
- **Single-file mode** — one HTML file. Snapshots live inside the file itself.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install --save-dev version-history-widget
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or use without installing:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx version-history-widget init
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Project mode
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx vh init # sets up .versions/, injects the widget, saves version 1
|
|
28
|
+
npx vh record "Title" -d "what changed and why"
|
|
29
|
+
npx vh restore 4 # safety-snapshots current state, then restores version 4
|
|
30
|
+
npx vh list [query] # list versions, optionally filtered
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Serve it so the restore button works:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node .versions/serve.js # standalone static server with restore endpoint
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or mount the middleware in your own dev server:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
// Express
|
|
43
|
+
app.use(require('./.versions/middleware.js')());
|
|
44
|
+
|
|
45
|
+
// Vite
|
|
46
|
+
export default {
|
|
47
|
+
plugins: [{
|
|
48
|
+
name: 'version-history',
|
|
49
|
+
configureServer(server) {
|
|
50
|
+
server.middlewares.use(require('./.versions/middleware.js')());
|
|
51
|
+
},
|
|
52
|
+
}],
|
|
53
|
+
};
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Single-file mode
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx vh-singlefile init page.html
|
|
60
|
+
npx vh-singlefile record page.html "Title" -d "details"
|
|
61
|
+
npx vh-singlefile list page.html [query]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Restoring is handled entirely in the browser by the injected widget — no
|
|
65
|
+
server needed.
|
|
66
|
+
|
|
67
|
+
## How it works
|
|
68
|
+
|
|
69
|
+
- No runtime dependencies — only Node's built-in `fs`, `path`, `http`, and
|
|
70
|
+
`child_process`.
|
|
71
|
+
- Every snapshot stores full file contents plus a generated diff, so restores
|
|
72
|
+
are exact and history is human-readable.
|
|
73
|
+
- The widget (`widget.js`) is vanilla JS + inline CSS, scoped under `vh-`
|
|
74
|
+
class names so it never collides with your site's styles.
|
|
75
|
+
|
|
76
|
+
## Customizing the widget UI
|
|
77
|
+
|
|
78
|
+
All of the widget's markup and styling live in `widget.js` as a single
|
|
79
|
+
template string. Open it directly and edit the `css` variable (colors,
|
|
80
|
+
spacing, fonts) or the HTML-building functions for layout changes — there's
|
|
81
|
+
no build step, so changes take effect on next reload.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/middleware.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* Connect/Express/Vite-compatible middleware: serves /.versions/* and handles POST /__vh/restore/:id
|
|
2
|
+
Express: app.use(require('./.versions/middleware.js')())
|
|
3
|
+
Vite: plugins:[{ name:'vh', configureServer(s){ s.middlewares.use(require('./.versions/middleware.js')()) } }]
|
|
4
|
+
Next: use serve.js alongside, or add a route handler that calls require('./.versions/vh.js').restore(id) */
|
|
5
|
+
const fs = require('fs'), path = require('path');
|
|
6
|
+
module.exports = function (opts = {}) {
|
|
7
|
+
const root = path.resolve(opts.root || process.env.VH_ROOT || process.cwd()), vdir = path.join(root, '.versions');
|
|
8
|
+
const types = { '.js': 'text/javascript', '.json': 'application/json', '.diff': 'text/plain' };
|
|
9
|
+
return function (req, res, next) {
|
|
10
|
+
const url = (req.url || '').split('?')[0];
|
|
11
|
+
if (req.method === 'POST' && url.startsWith('/__vh/restore/')) {
|
|
12
|
+
const id = Number(url.split('/').pop());
|
|
13
|
+
try { const out = require(path.join(vdir, 'vh.js')).restore(id); res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); return res.end(out); }
|
|
14
|
+
catch (e) { res.statusCode = 500; return res.end(String(e.stderr || e.message)); }
|
|
15
|
+
}
|
|
16
|
+
if (url.startsWith('/.versions/')) {
|
|
17
|
+
const f = path.join(vdir, url.slice('/.versions/'.length));
|
|
18
|
+
if (!f.startsWith(vdir) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.statusCode = 404; return res.end(); }
|
|
19
|
+
res.setHeader('Content-Type', types[path.extname(f)] || 'application/octet-stream'); res.setHeader('Cache-Control', 'no-store');
|
|
20
|
+
return fs.createReadStream(f).pipe(res);
|
|
21
|
+
}
|
|
22
|
+
next && next();
|
|
23
|
+
};
|
|
24
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "version-history-widget",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency version history for web projects: snapshots every change and adds a floating widget to browse, search, and restore any version.",
|
|
5
|
+
"keywords": ["version history", "undo", "snapshot", "restore", "dev-tool", "widget"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "bimanshu <bimanshuvatsa@gmail.com>",
|
|
8
|
+
"engines": { "node": ">=14" },
|
|
9
|
+
"main": "./middleware.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"vh": "./vh.js",
|
|
12
|
+
"vh-singlefile": "./singlefile.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"vh.js",
|
|
16
|
+
"widget.js",
|
|
17
|
+
"middleware.js",
|
|
18
|
+
"serve.js",
|
|
19
|
+
"singlefile.js"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/bimanshu/version-history-widget.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/bimanshu/version-history-widget#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/bimanshu/version-history-widget/issues"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/serve.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* Static-site-agnostic dev server with the restore endpoint. Zero deps.
|
|
2
|
+
node .versions/serve.js [port] [dir] — serves the project folder (default: parent of .versions, port 4173) */
|
|
3
|
+
const http = require('http'), fs = require('fs'), path = require('path');
|
|
4
|
+
const port = Number(process.argv[2]) || 4173, root = path.resolve(process.argv[3] || path.join(__dirname, '..'));
|
|
5
|
+
process.env.VH_ROOT = root;
|
|
6
|
+
const vh = require(path.join(__dirname, 'middleware.js'))({ root });
|
|
7
|
+
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
|
|
8
|
+
http.createServer((req, res) => vh(req, res, () => {
|
|
9
|
+
let f = path.join(root, decodeURIComponent(req.url.split('?')[0]));
|
|
10
|
+
if (fs.existsSync(f) && fs.statSync(f).isDirectory()) f = path.join(f, 'index.html');
|
|
11
|
+
if (!f.startsWith(root) || !fs.existsSync(f)) { res.statusCode = 404; return res.end('Not found'); }
|
|
12
|
+
res.setHeader('Content-Type', types[path.extname(f)] || 'application/octet-stream'); res.setHeader('Cache-Control', 'no-store');
|
|
13
|
+
fs.createReadStream(f).pipe(res);
|
|
14
|
+
})).listen(port, () => console.log('version-history server: http://localhost:' + port + ' (root: ' + root + ')'));
|
package/singlefile.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Single-file mode: embeds version history inside one HTML file. Zero deps.
|
|
3
|
+
node singlefile.js init page.html — inject store + widget, save version 1
|
|
4
|
+
node singlefile.js record page.html "Title" [-d "details" | -f file]
|
|
5
|
+
node singlefile.js list page.html [query] */
|
|
6
|
+
const fs = require('fs'), path = require('path');
|
|
7
|
+
const STORE = /<script type="application\/json" id="vh-store">([\s\S]*?)<\/script>/;
|
|
8
|
+
const [cmd, file, ...args] = process.argv.slice(2);
|
|
9
|
+
if (!cmd || !file) { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); process.exit(0); }
|
|
10
|
+
let html = fs.readFileSync(file, 'utf8');
|
|
11
|
+
const m = html.match(STORE), versions = m ? JSON.parse(m[1].replace(/<\\\/script/g, '</script')) : [];
|
|
12
|
+
const bare = () => html.replace(/<script type="application\/json" id="vh-store">[\s\S]*?<\/script>\n?/, '').replace(/<script id="vh-widget">[\s\S]*?<\/script>\n?/, '');
|
|
13
|
+
const write = v => {
|
|
14
|
+
let out = bare();
|
|
15
|
+
const store = '<script type="application/json" id="vh-store">' + JSON.stringify(v).replace(/<\/script/g, '<\\/script') + '</script>';
|
|
16
|
+
const widget = '<script id="vh-widget">' + fs.readFileSync(path.join(__dirname, 'widget.js'), 'utf8') + '</script>';
|
|
17
|
+
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, store + '\n' + widget + '\n</body>') : out + store + widget;
|
|
18
|
+
fs.writeFileSync(file, out);
|
|
19
|
+
};
|
|
20
|
+
if (cmd === 'init') {
|
|
21
|
+
if (versions.length) { console.log('Already initialised (' + versions.length + ' versions).'); process.exit(0); }
|
|
22
|
+
versions.push({ id: 1, title: 'Initial version', time: new Date().toISOString(), details: 'State of the file when version history was enabled.', html: bare() });
|
|
23
|
+
write(versions); console.log('version-history ready in ' + file + '. Saved version 1.');
|
|
24
|
+
} else if (cmd === 'record') {
|
|
25
|
+
const title = args[0]; if (!title) { console.error('usage: singlefile record page.html "Title" [-d details]'); process.exit(1); }
|
|
26
|
+
const di = args.indexOf('-d'), fi = args.indexOf('-f'), details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
|
|
27
|
+
const snap = bare(); if (versions.length && versions[versions.length - 1].html === snap) { console.log('No changes since last version — nothing saved.'); process.exit(0); }
|
|
28
|
+
const id = versions.length ? versions[versions.length - 1].id + 1 : 1;
|
|
29
|
+
versions.push({ id, title, time: new Date().toISOString(), details, html: snap }); write(versions);
|
|
30
|
+
console.log('Saved as version ' + id + ': ' + title);
|
|
31
|
+
} else if (cmd === 'list') {
|
|
32
|
+
const q = (args[0] || '').toLowerCase();
|
|
33
|
+
for (const v of versions) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
|
|
34
|
+
}
|
package/vh.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* version-history CLI (project mode). Zero dependencies.
|
|
3
|
+
node vh.js init — set up .versions/, inject widget, snapshot as version 1
|
|
4
|
+
node vh.js record "Title" [-d "details" | -f details.txt] — snapshot changed files as a new version
|
|
5
|
+
node vh.js restore <id> — snapshot current state, then restore version <id>
|
|
6
|
+
node vh.js list [query] — list versions (optionally filtered by title/details)
|
|
7
|
+
Env: VH_ROOT (project root, default cwd) */
|
|
8
|
+
const fs = require('fs'), path = require('path');
|
|
9
|
+
const ROOT = path.resolve(process.env.VH_ROOT || process.cwd());
|
|
10
|
+
const VDIR = path.join(ROOT, '.versions'), SNAP = path.join(VDIR, 'snapshots'), MAN = path.join(VDIR, 'manifest.json');
|
|
11
|
+
const IGNORE = new Set(['node_modules', '.git', '.versions', 'dist', 'build', '.next', '.nuxt', 'coverage', '.cache']);
|
|
12
|
+
const MAX = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const readMan = () => fs.existsSync(MAN) ? JSON.parse(fs.readFileSync(MAN, 'utf8')) : [];
|
|
15
|
+
const writeMan = m => fs.writeFileSync(MAN, JSON.stringify(m, null, 2));
|
|
16
|
+
|
|
17
|
+
function walk(dir, out = {}) {
|
|
18
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (IGNORE.has(e.name) || e.name.startsWith('.env')) continue;
|
|
20
|
+
const p = path.join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) walk(p, out);
|
|
22
|
+
else if (e.isFile() && fs.statSync(p).size <= MAX) out[path.relative(ROOT, p).split(path.sep).join('/')] = fs.readFileSync(p);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Reconstruct full file state at version id by replaying snapshots. */
|
|
28
|
+
function stateAt(id, man) {
|
|
29
|
+
const files = {};
|
|
30
|
+
for (const v of man) {
|
|
31
|
+
if (v.id > id) break;
|
|
32
|
+
for (const d of v.deleted || []) delete files[d];
|
|
33
|
+
for (const f of v.files || []) files[f] = fs.readFileSync(path.join(SNAP, String(v.id), f));
|
|
34
|
+
}
|
|
35
|
+
return files;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineDiff(a, b) { // small LCS diff, unified-ish output
|
|
39
|
+
const A = a.split('\n'), B = b.split('\n');
|
|
40
|
+
if (A.length * B.length > 4e6) return '(diff too large to display)';
|
|
41
|
+
const n = A.length, m = B.length, dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
42
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
43
|
+
const out = []; let i = 0, j = 0;
|
|
44
|
+
while (i < n && j < m) { if (A[i] === B[j]) { i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) out.push('- ' + A[i++]); else out.push('+ ' + B[j++]); }
|
|
45
|
+
while (i < n) out.push('- ' + A[i++]); while (j < m) out.push('+ ' + B[j++]);
|
|
46
|
+
return out.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function snapshot(title, details, prevState) {
|
|
50
|
+
const man = readMan(), id = man.length ? man[man.length - 1].id + 1 : 1;
|
|
51
|
+
const cur = walk(ROOT), files = [], deleted = [], diffs = [];
|
|
52
|
+
for (const f of Object.keys(cur)) {
|
|
53
|
+
const old = prevState[f];
|
|
54
|
+
if (!old || !old.equals(cur[f])) {
|
|
55
|
+
files.push(f);
|
|
56
|
+
const dest = path.join(SNAP, String(id), f); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, cur[f]);
|
|
57
|
+
const isText = !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|pdf|zip)$/i.test(f);
|
|
58
|
+
diffs.push('### ' + f + (old ? '' : ' (new)') + '\n' + (isText ? lineDiff(old ? old.toString('utf8') : '', cur[f].toString('utf8')) : '(binary)'));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const f of Object.keys(prevState)) if (!cur[f]) { deleted.push(f); diffs.push('### ' + f + ' (deleted)'); }
|
|
62
|
+
fs.mkdirSync(path.join(SNAP, String(id)), { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(SNAP, String(id), 'changes.diff'), diffs.join('\n\n'));
|
|
64
|
+
const bundle = {}; for (const f of Object.keys(cur)) bundle[f] = cur[f].toString('base64');
|
|
65
|
+
fs.writeFileSync(path.join(SNAP, String(id), 'files.json'), JSON.stringify({ id, title, files: bundle }));
|
|
66
|
+
man.push({ id, title, time: new Date().toISOString(), details, files, deleted });
|
|
67
|
+
writeMan(man);
|
|
68
|
+
return { id, changed: files.length + deleted.length };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function injectWidget() {
|
|
72
|
+
const cands = ['index.html', 'public/index.html', 'src/index.html', 'app/layout.tsx', 'app/layout.jsx', 'src/app/layout.tsx', 'pages/_document.tsx', 'pages/_document.jsx']
|
|
73
|
+
.map(f => path.join(ROOT, f)).filter(fs.existsSync);
|
|
74
|
+
for (const f of cands) {
|
|
75
|
+
let s = fs.readFileSync(f, 'utf8'); if (s.includes('.versions/widget.js')) return f;
|
|
76
|
+
const tag = '<script src="/.versions/widget.js"></script>';
|
|
77
|
+
if (/<\/body>/i.test(s)) s = s.replace(/<\/body>/i, tag + '\n</body>');
|
|
78
|
+
else if (/<\/head>/i.test(s)) s = s.replace(/<\/head>/i, tag + '\n</head>');
|
|
79
|
+
else continue;
|
|
80
|
+
fs.writeFileSync(f, s); return f;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cmd = process.argv[2], args = process.argv.slice(3);
|
|
86
|
+
if (cmd === 'init') {
|
|
87
|
+
fs.mkdirSync(SNAP, { recursive: true });
|
|
88
|
+
for (const f of ['widget.js', 'vh.js', 'serve.js', 'middleware.js']) { const src = path.join(__dirname, f); if (fs.existsSync(src) && src !== path.join(VDIR, f)) fs.copyFileSync(src, path.join(VDIR, f)); }
|
|
89
|
+
if (!fs.existsSync(MAN)) writeMan([]);
|
|
90
|
+
const gi = path.join(ROOT, '.gitignore'); const line = '.versions/snapshots/';
|
|
91
|
+
if (!fs.existsSync(gi) || !fs.readFileSync(gi, 'utf8').includes(line)) fs.appendFileSync(gi, '\n' + line + '\n');
|
|
92
|
+
const where = injectWidget();
|
|
93
|
+
const r = readMan().length ? null : snapshot('Initial version', 'State of the project when version history was enabled.', {});
|
|
94
|
+
console.log('version-history ready.' + (where ? ' Widget injected into ' + path.relative(ROOT, where) + '.' : ' Could not find an entry HTML — add <script src="/.versions/widget.js"></script> manually.') + (r ? ' Saved version 1.' : ''));
|
|
95
|
+
console.log('Serve with restore endpoint: node .versions/serve.js (or mount .versions/middleware.js in your dev server)');
|
|
96
|
+
} else if (cmd === 'record') {
|
|
97
|
+
const title = args[0]; if (!title) { console.error('usage: vh record "Title" [-d "details" | -f file]'); process.exit(1); }
|
|
98
|
+
const di = args.indexOf('-d'), fi = args.indexOf('-f');
|
|
99
|
+
const details = di >= 0 ? args[di + 1] : fi >= 0 ? fs.readFileSync(args[fi + 1], 'utf8') : '';
|
|
100
|
+
const man = readMan(), prev = man.length ? stateAt(man[man.length - 1].id, man) : {};
|
|
101
|
+
const r = snapshot(title, details, prev);
|
|
102
|
+
console.log(r.changed ? 'Saved as version ' + r.id + ': ' + title : 'No changes since last version — nothing saved.');
|
|
103
|
+
if (!r.changed) { const m = readMan(); m.pop(); writeMan(m); fs.rmSync(path.join(SNAP, String(r.id)), { recursive: true, force: true }); }
|
|
104
|
+
} else if (cmd === 'restore') {
|
|
105
|
+
const id = Number(args[0]), man = readMan(), target = man.find(v => v.id === id);
|
|
106
|
+
if (!target) { console.error('No version ' + args[0]); process.exit(1); }
|
|
107
|
+
const cur = man[man.length - 1], prev = stateAt(cur.id, man);
|
|
108
|
+
snapshot('Snapshot before restoring "' + target.title + '"', 'Automatic safety snapshot taken before restore.', prev);
|
|
109
|
+
const want = stateAt(id, readMan()), have = walk(ROOT);
|
|
110
|
+
for (const f of Object.keys(have)) if (!want[f]) fs.rmSync(path.join(ROOT, f));
|
|
111
|
+
for (const f of Object.keys(want)) { const p = path.join(ROOT, f); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, want[f]); }
|
|
112
|
+
injectWidget();
|
|
113
|
+
const r = snapshot('Restored "' + target.title + '"', 'Restored the state saved as version ' + id + '.', stateAt(readMan().slice(-1)[0].id, readMan()));
|
|
114
|
+
console.log('Restored version ' + id + ' (recorded as version ' + r.id + ').');
|
|
115
|
+
} else if (cmd === 'list') {
|
|
116
|
+
const q = (args[0] || '').toLowerCase();
|
|
117
|
+
for (const v of readMan()) if (!q || v.title.toLowerCase().includes(q) || (v.details || '').toLowerCase().includes(q)) console.log(String(v.id).padStart(3) + ' ' + v.time.slice(0, 16).replace('T', ' ') + ' ' + v.title);
|
|
118
|
+
} else { console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].split('\n').slice(1).join('\n')); }
|
|
119
|
+
module.exports = { restore: id => { const { execFileSync } = require('child_process'); return execFileSync(process.execPath, [__filename, 'restore', String(id)], { cwd: ROOT, env: process.env }).toString(); } };
|
package/widget.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* version-history widget - vanilla JS, no deps. Reads #vh-store (single-file) or /.versions/manifest.json (project). */
|
|
2
|
+
(function () {
|
|
3
|
+
if (window.__vhLoaded) return; window.__vhLoaded = true;
|
|
4
|
+
var Z = 2147483647, MODE = document.getElementById('vh-store') ? 'single' : 'project';
|
|
5
|
+
var css = '\
|
|
6
|
+
.vh-pill{position:fixed;right:16px;bottom:16px;z-index:' + Z + ';font:13px/1 system-ui,sans-serif;background:#111;color:#fff;border:0;border-radius:999px;padding:10px 14px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.25)}\
|
|
7
|
+
.vh-panel{position:fixed;right:16px;bottom:56px;width:440px;max-width:calc(100vw - 32px);max-height:70vh;z-index:' + Z + ';background:#fff;color:#111;border:1px solid #ddd;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.25);font:13px/1.4 system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden}\
|
|
8
|
+
.vh-head{display:flex;gap:8px;padding:10px;border-bottom:1px solid #eee;align-items:center}\
|
|
9
|
+
.vh-search{flex:1;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font:inherit;outline:none}.vh-search:focus{border-color:#111}\
|
|
10
|
+
.vh-clear{border:0;background:#eee;border-radius:6px;padding:6px 8px;cursor:pointer}\
|
|
11
|
+
.vh-list{overflow:auto;padding:6px}\
|
|
12
|
+
.vh-row{border-bottom:1px solid #f0f0f0;padding:8px 6px}\
|
|
13
|
+
.vh-top{display:flex;align-items:center;gap:8px;margin-top:6px}\
|
|
14
|
+
.vh-title{display:block;font-weight:600;line-height:1.35}.vh-title mark{background:#fff3a3;padding:0}\
|
|
15
|
+
.vh-time{flex:1;color:#777;font-size:11px;white-space:nowrap}\
|
|
16
|
+
.vh-btn{border:1px solid #ccc;background:#fafafa;border-radius:6px;padding:4px 8px;cursor:pointer;font:inherit;font-size:12px}\
|
|
17
|
+
.vh-btn:disabled{opacity:.4;cursor:default}.vh-btn.vh-restore{background:#111;color:#fff;border-color:#111}\
|
|
18
|
+
.vh-cur{color:#0a7d34;font-size:11px}.vh-tag{color:#777;font-size:11px;font-style:italic}\
|
|
19
|
+
.vh-details{display:none;margin-top:8px;padding:10px;background:#f7f7f7;border-radius:8px;white-space:pre-wrap;color:#333}\
|
|
20
|
+
.vh-details.vh-open{display:block}.vh-empty{padding:20px;text-align:center;color:#777}\
|
|
21
|
+
.vh-note{padding:8px 10px;font-size:12px;background:#fff8e1;border-top:1px solid #eee;display:none}';
|
|
22
|
+
var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
|
|
23
|
+
|
|
24
|
+
var versions = [], pill, panel, open = false, openDetails = null, query = '';
|
|
25
|
+
|
|
26
|
+
function load(cb) {
|
|
27
|
+
if (MODE === 'single') { try { versions = JSON.parse(document.getElementById('vh-store').textContent || '[]'); } catch (e) { versions = []; } cb(); }
|
|
28
|
+
else fetch('/.versions/manifest.json', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (m) { versions = m; cb(); }).catch(function () { versions = []; cb(); });
|
|
29
|
+
}
|
|
30
|
+
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
|
31
|
+
function hl(text, q) { if (!q) return esc(text); var out = esc(text); q.split(/\s+/).filter(Boolean).forEach(function (w) { out = out.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi'), '<mark>$1</mark>'); }); return out; }
|
|
32
|
+
function matches(v, q) { var words = q.toLowerCase().split(/\s+/).filter(Boolean); if (!words.length) return { ok: true }; var t = v.title.toLowerCase(), d = (v.details || '').toLowerCase(); var ok = words.every(function (w) { return t.indexOf(w) >= 0 || d.indexOf(w) >= 0; }); return { ok: ok, inTitleOnly: words.some(function (w) { return t.indexOf(w) >= 0; }) }; }
|
|
33
|
+
function fmt(t) { var d = new Date(t); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); }
|
|
34
|
+
|
|
35
|
+
function render() {
|
|
36
|
+
var list = panel.querySelector('.vh-list'); list.innerHTML = '';
|
|
37
|
+
var cur = versions.length ? versions[versions.length - 1].id : null, shown = 0;
|
|
38
|
+
versions.slice().reverse().forEach(function (v) {
|
|
39
|
+
var m = matches(v, query); if (!m.ok) return; shown++;
|
|
40
|
+
var row = document.createElement('div'); row.className = 'vh-row';
|
|
41
|
+
row.innerHTML = '<span class="vh-title">' + hl(v.title, query) + (query && !m.inTitleOnly ? ' <span class="vh-tag">match in details</span>' : '') + (v.id === cur ? ' <span class="vh-cur">\u25CF current</span>' : '') + '</span><div class="vh-top">' +
|
|
42
|
+
'<span class="vh-time">' + esc(fmt(v.time)) + '</span>' +
|
|
43
|
+
'<button class="vh-btn vh-det">Details \u25BE</button>' +
|
|
44
|
+
'<button class="vh-btn vh-restore"' + (v.id === cur ? ' disabled' : '') + '>Restore</button></div>' +
|
|
45
|
+
'<div class="vh-details' + (openDetails === v.id ? ' vh-open' : '') + '">' + hl(v.details || '(no details)', query) + '</div>';
|
|
46
|
+
row.querySelector('.vh-det').onclick = function () { openDetails = openDetails === v.id ? null : v.id; render(); };
|
|
47
|
+
row.querySelector('.vh-restore').onclick = function () { if (confirm('Restore "' + v.title + '"? Current state will be saved first.')) restore(v); };
|
|
48
|
+
list.appendChild(row);
|
|
49
|
+
});
|
|
50
|
+
if (!shown) list.innerHTML = '<div class="vh-empty">' + (query ? 'No versions match "' + esc(query) + '"' : 'No versions yet') + '</div>';
|
|
51
|
+
pill.textContent = 'Versions \u00B7 ' + versions.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function restore(v) {
|
|
55
|
+
if (MODE === 'single') {
|
|
56
|
+
var snap = versions[versions.length - 1], now = new Date().toISOString();
|
|
57
|
+
var html = document.documentElement.outerHTML.replace(/<script type="application\/json" id="vh-store">[\s\S]*?<\/script>/, '');
|
|
58
|
+
versions.push({ id: versions.length + 1, title: 'Snapshot before restoring "' + v.title + '"', time: now, details: 'Automatic safety snapshot taken before restore.', html: html });
|
|
59
|
+
versions.push({ id: versions.length + 1, title: 'Restored "' + v.title + '"', time: now, details: 'Restored the state saved as version ' + v.id + '.', html: v.html });
|
|
60
|
+
var store = '<script type="application/json" id="vh-store">' + JSON.stringify(versions).replace(/<\/script/g, '<\\/script') + '<\/script>';
|
|
61
|
+
var out = v.html.replace(/<\/body>/i, store + '</body>');
|
|
62
|
+
document.open(); document.write(out); document.close(); return;
|
|
63
|
+
}
|
|
64
|
+
fetch('/__vh/restore/' + v.id, { method: 'POST' }).then(function (r) { if (!r.ok) throw 0; location.reload(); }).catch(function () {
|
|
65
|
+
fetch('/.versions/snapshots/' + v.id + '/files.json', { cache: 'no-store' }).then(function (r) { return r.blob(); }).then(function (b) {
|
|
66
|
+
var a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'snapshot-' + v.id + '.json'; a.click();
|
|
67
|
+
}).catch(function () { });
|
|
68
|
+
var n = panel.querySelector('.vh-note'); n.style.display = 'block'; n.textContent = 'No live restore endpoint here. Run locally: node .versions/vh.js restore ' + v.id;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function build() {
|
|
73
|
+
pill = document.createElement('button'); pill.className = 'vh-pill'; pill.textContent = 'Versions'; pill.onclick = toggle; document.body.appendChild(pill);
|
|
74
|
+
panel = document.createElement('div'); panel.className = 'vh-panel'; panel.style.display = 'none';
|
|
75
|
+
panel.innerHTML = '<div class="vh-head"><input class="vh-search" placeholder="Search versions\u2026"><button class="vh-clear" title="Clear">\u00D7</button></div><div class="vh-list"></div><div class="vh-note"></div>';
|
|
76
|
+
var inp = panel.querySelector('.vh-search'); inp.oninput = function () { query = inp.value; render(); };
|
|
77
|
+
panel.querySelector('.vh-clear').onclick = function () { inp.value = ''; query = ''; render(); inp.focus(); };
|
|
78
|
+
document.addEventListener('keydown', function (e) { if (e.key !== 'Escape' || !open) return; if (query) { inp.value = ''; query = ''; render(); } else toggle(); });
|
|
79
|
+
document.body.appendChild(panel);
|
|
80
|
+
}
|
|
81
|
+
function toggle() { open = !open; if (open) load(function () { panel.style.display = 'flex'; render(); panel.querySelector('.vh-search').focus(); }); else panel.style.display = 'none'; }
|
|
82
|
+
function init() { build(); load(render); }
|
|
83
|
+
if (document.body) init(); else document.addEventListener('DOMContentLoaded', init);
|
|
84
|
+
})();
|