reladraw 0.27.0 → 0.28.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/.claude/skills/reladraw/SKILL.md +7 -5
- package/.claude/skills/reladraw/reference/syntax.md +44 -5
- package/README.md +2 -2
- package/SYNTAX.md +44 -5
- package/dist/cli.js +15 -1
- package/dist/examples.d.ts +26 -0
- package/dist/examples.js +56 -0
- package/dist/page-data.d.ts +130 -0
- package/dist/page-data.js +226 -0
- package/dist/page.d.ts +46 -2
- package/dist/page.js +1239 -241
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +544 -0
- package/examples/arch.reladraw +95 -0
- package/examples/around.reladraw +101 -0
- package/examples/borders.reladraw +28 -0
- package/examples/bundles.reladraw +49 -0
- package/examples/circles.reladraw +37 -0
- package/examples/coincident.reladraw +66 -0
- package/examples/contents.reladraw +52 -0
- package/examples/corridors.reladraw +48 -0
- package/examples/deck.reladraw +26 -0
- package/examples/flowchart.reladraw +23 -0
- package/examples/frames.reladraw +79 -0
- package/examples/gap.reladraw +21 -0
- package/examples/gaps.reladraw +25 -0
- package/examples/icons.reladraw +35 -0
- package/examples/lanes.reladraw +54 -0
- package/examples/lines.reladraw +73 -0
- package/examples/linetexts.reladraw +48 -0
- package/examples/marks.reladraw +39 -0
- package/examples/names.reladraw +23 -0
- package/examples/outlines.reladraw +56 -0
- package/examples/overhang.reladraw +24 -0
- package/examples/overlays.reladraw +75 -0
- package/examples/parts.reladraw +31 -0
- package/examples/passes.reladraw +56 -0
- package/examples/points.reladraw +28 -0
- package/examples/regions.reladraw +14 -0
- package/examples/routing.reladraw +88 -0
- package/examples/shapes.reladraw +38 -0
- package/examples/snug.reladraw +8 -0
- package/examples/text.reladraw +69 -0
- package/examples/themes.reladraw +16 -0
- package/examples/urls.reladraw +35 -0
- package/examples/venn.reladraw +15 -0
- package/package.json +2 -1
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reladraw serve`: a page for one diagram, served on this machine and tied to
|
|
3
|
+
* its file. The page redraws whenever the file changes on disk, whoever
|
|
4
|
+
* changed it, so a person and an agent can both work on the one file.
|
|
5
|
+
*
|
|
6
|
+
* One server serves a folder, the one it was started in. Asked for a second
|
|
7
|
+
* file in that folder, `serve` finds the server already running and opens a
|
|
8
|
+
* page on it rather than starting another. It listens only on this machine,
|
|
9
|
+
* and serves only `.reladraw` files inside the folder, and the library the
|
|
10
|
+
* page draws with.
|
|
11
|
+
*/
|
|
12
|
+
/** The port tried first, and how many after it are tried before giving up. */
|
|
13
|
+
export declare const FIRST_PORT = 4747;
|
|
14
|
+
export declare function serve(args: string[]): Promise<number>;
|
|
15
|
+
/** `reladraw note`: run from the folder the server serves, as serve is. */
|
|
16
|
+
export declare function noteCommand(args: string[]): Promise<number>;
|
|
17
|
+
/** Opens an address in the default browser, as the skill's pages are opened. */
|
|
18
|
+
export declare function openInBrowser(address: string): void;
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { watch } from 'node:fs';
|
|
3
|
+
import { readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer, request } from 'node:http';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { OFFERED, START } from './examples.js';
|
|
9
|
+
import { SETTINGS_FILE } from './format-settings.js';
|
|
10
|
+
import { drawPage, playgroundPage } from './page.js';
|
|
11
|
+
/**
|
|
12
|
+
* `reladraw serve`: a page for one diagram, served on this machine and tied to
|
|
13
|
+
* its file. The page redraws whenever the file changes on disk, whoever
|
|
14
|
+
* changed it, so a person and an agent can both work on the one file.
|
|
15
|
+
*
|
|
16
|
+
* One server serves a folder, the one it was started in. Asked for a second
|
|
17
|
+
* file in that folder, `serve` finds the server already running and opens a
|
|
18
|
+
* page on it rather than starting another. It listens only on this machine,
|
|
19
|
+
* and serves only `.reladraw` files inside the folder, and the library the
|
|
20
|
+
* page draws with.
|
|
21
|
+
*/
|
|
22
|
+
/** The port tried first, and how many after it are tried before giving up. */
|
|
23
|
+
export const FIRST_PORT = 4747;
|
|
24
|
+
const PORTS = 20;
|
|
25
|
+
const HELP = `reladraw serve [<file.reladraw>] [--no-open]
|
|
26
|
+
|
|
27
|
+
Serves a page for the diagram on this machine and opens it in the browser.
|
|
28
|
+
The page redraws whenever the file changes on disk, and its Save writes the
|
|
29
|
+
file. With no file, the page is a playground: the examples, and a Save that
|
|
30
|
+
names a file in the folder, after which the page follows that file. One
|
|
31
|
+
server serves the folder it was started in: serving another file in that
|
|
32
|
+
folder opens a page on the server already running. Ctrl-C stops it.
|
|
33
|
+
|
|
34
|
+
--no-open print the page's address instead of opening it.
|
|
35
|
+
`;
|
|
36
|
+
/** Where the library lives: the compiled files beside this one. */
|
|
37
|
+
const LIB = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
/** The examples a page with no file offers, which the package carries beside the library. */
|
|
39
|
+
async function examples() {
|
|
40
|
+
const offered = [START];
|
|
41
|
+
for (const [file, name, about] of OFFERED) {
|
|
42
|
+
try {
|
|
43
|
+
offered.push({ name, about, source: await readFile(join(LIB, '..', 'examples', `${file}.reladraw`), 'utf8') });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// A copy of the package without its examples still has a playground.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return offered;
|
|
50
|
+
}
|
|
51
|
+
/** A folder as the page names it: from the home folder, as ~/proj, when it is inside it. */
|
|
52
|
+
function shown(folder) {
|
|
53
|
+
const home = homedir();
|
|
54
|
+
const rel = relative(home, folder);
|
|
55
|
+
return rel === '' ? '~' : !rel.startsWith('..') && !isAbsolute(rel) ? `~/${rel.split(sep).join('/')}` : folder;
|
|
56
|
+
}
|
|
57
|
+
/** How many versions a file keeps. */
|
|
58
|
+
const KEPT = 20;
|
|
59
|
+
function record(entry, text, who, at = Date.now()) {
|
|
60
|
+
entry.text = text;
|
|
61
|
+
entry.at = at;
|
|
62
|
+
entry.changed = who !== 'opened';
|
|
63
|
+
entry.versions.push({ text, at, who });
|
|
64
|
+
if (entry.versions.length > KEPT)
|
|
65
|
+
entry.versions.splice(0, entry.versions.length - KEPT);
|
|
66
|
+
}
|
|
67
|
+
function newEntry() {
|
|
68
|
+
return { text: null, at: Date.now(), changed: false, listeners: new Set(), watcher: null, timer: null, versions: [] };
|
|
69
|
+
}
|
|
70
|
+
/** The entry for a file, its text read if it hasn't been yet. */
|
|
71
|
+
async function entryFor(root, followed, path) {
|
|
72
|
+
let entry = followed.get(path);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = newEntry();
|
|
75
|
+
followed.set(path, entry);
|
|
76
|
+
}
|
|
77
|
+
if (entry.text === null)
|
|
78
|
+
record(entry, await readFile(join(root, path), 'utf8'), 'opened');
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
function broadcast(entry) {
|
|
82
|
+
for (const listener of entry.listeners)
|
|
83
|
+
send(listener, entry);
|
|
84
|
+
}
|
|
85
|
+
async function body(req) {
|
|
86
|
+
let text = '';
|
|
87
|
+
req.setEncoding('utf8');
|
|
88
|
+
for await (const chunk of req) {
|
|
89
|
+
text += chunk;
|
|
90
|
+
if (text.length > 5_000_000)
|
|
91
|
+
throw new Error('too large');
|
|
92
|
+
}
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
export async function serve(args) {
|
|
96
|
+
let file;
|
|
97
|
+
let open = true;
|
|
98
|
+
for (const arg of args) {
|
|
99
|
+
if (arg === '-h' || arg === '--help') {
|
|
100
|
+
process.stdout.write(HELP);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
else if (arg === '--no-open') {
|
|
104
|
+
open = false;
|
|
105
|
+
}
|
|
106
|
+
else if (arg.startsWith('-')) {
|
|
107
|
+
process.stderr.write(`reladraw: serve doesn't take ${arg}; it takes a file and --no-open\n`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
else if (file !== undefined) {
|
|
111
|
+
process.stderr.write(`reladraw: serve takes one file; serve each in turn, and they share one server\n`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
file = arg;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const root = await realpath(process.cwd());
|
|
119
|
+
let path = '';
|
|
120
|
+
if (file !== undefined) {
|
|
121
|
+
const inside = await insideRoot(root, file);
|
|
122
|
+
if (typeof inside !== 'string') {
|
|
123
|
+
process.stderr.write(`reladraw: ${inside.problem}\n`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
path = inside;
|
|
127
|
+
}
|
|
128
|
+
// A server already serving this folder is used as it is.
|
|
129
|
+
for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
|
|
130
|
+
const there = await whoIsOn(port);
|
|
131
|
+
if (there === null)
|
|
132
|
+
continue;
|
|
133
|
+
if (there.root === root) {
|
|
134
|
+
const address = pageAddress(port, path);
|
|
135
|
+
process.stdout.write(`reladraw is already serving ${root} at http://127.0.0.1:${port}/\n${address}\n`);
|
|
136
|
+
if (open)
|
|
137
|
+
openInBrowser(address);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const port = await listen(root);
|
|
142
|
+
if (port === null) {
|
|
143
|
+
process.stderr.write(`reladraw: no free port from ${FIRST_PORT} to ${FIRST_PORT + PORTS - 1}\n`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const address = pageAddress(port, path);
|
|
147
|
+
process.stdout.write(`reladraw is serving ${root} at http://127.0.0.1:${port}/ — Ctrl-C stops it\n${address}\n`);
|
|
148
|
+
if (open)
|
|
149
|
+
openInBrowser(address);
|
|
150
|
+
// Runs until stopped.
|
|
151
|
+
return new Promise(() => { });
|
|
152
|
+
}
|
|
153
|
+
function pageAddress(port, path) {
|
|
154
|
+
return `http://127.0.0.1:${port}/${path.split('/').map(encodeURIComponent).join('/')}`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The file's path inside the folder, with forward slashes, or what is wrong
|
|
158
|
+
* with it: outside the folder, not a .reladraw file, or not there.
|
|
159
|
+
*/
|
|
160
|
+
async function insideRoot(root, file) {
|
|
161
|
+
if (extname(file) !== '.reladraw')
|
|
162
|
+
return { problem: `${file} isn't a .reladraw file`, status: 404 };
|
|
163
|
+
const full = isAbsolute(file) ? file : resolvePath(root, file);
|
|
164
|
+
let real;
|
|
165
|
+
try {
|
|
166
|
+
real = await realpath(full);
|
|
167
|
+
if (!(await stat(real)).isFile())
|
|
168
|
+
return { problem: `${file} isn't a file`, status: 404 };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { problem: `there is no ${file}`, status: 404 };
|
|
172
|
+
}
|
|
173
|
+
const rel = relative(root, real);
|
|
174
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
175
|
+
return {
|
|
176
|
+
problem: `${file} is outside ${root}, the folder serve was started in; start it in a folder that holds the file`,
|
|
177
|
+
status: 403,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return rel.split(sep).join('/');
|
|
181
|
+
}
|
|
182
|
+
/** Which folder a reladraw server on this port serves, or null if none is there. */
|
|
183
|
+
function whoIsOn(port) {
|
|
184
|
+
return new Promise((done) => {
|
|
185
|
+
const asking = request({ host: '127.0.0.1', port, path: '/.reladraw', timeout: 800 }, (response) => {
|
|
186
|
+
let body = '';
|
|
187
|
+
response.setEncoding('utf8');
|
|
188
|
+
response.on('data', (chunk) => (body += chunk));
|
|
189
|
+
response.on('end', () => {
|
|
190
|
+
try {
|
|
191
|
+
const told = JSON.parse(body);
|
|
192
|
+
done(told.reladraw && typeof told.root === 'string' ? { root: told.root } : null);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
done(null);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
asking.on('error', () => done(null));
|
|
200
|
+
asking.on('timeout', () => {
|
|
201
|
+
asking.destroy();
|
|
202
|
+
done(null);
|
|
203
|
+
});
|
|
204
|
+
asking.end();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function listen(root) {
|
|
208
|
+
const followed = new Map();
|
|
209
|
+
const server = createServer((req, res) => {
|
|
210
|
+
handle(root, followed, req, res).catch((error) => {
|
|
211
|
+
if (!res.headersSent)
|
|
212
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
213
|
+
res.end(String(error instanceof Error ? error.message : error));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
return new Promise((done) => {
|
|
217
|
+
const attempt = (port) => {
|
|
218
|
+
if (port >= FIRST_PORT + PORTS)
|
|
219
|
+
return done(null);
|
|
220
|
+
server.once('error', () => attempt(port + 1));
|
|
221
|
+
server.listen(port, '127.0.0.1', () => {
|
|
222
|
+
server.removeAllListeners('error');
|
|
223
|
+
done(port);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
attempt(FIRST_PORT);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const TYPES = {
|
|
230
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
231
|
+
'.html': 'text/html; charset=utf-8',
|
|
232
|
+
};
|
|
233
|
+
async function handle(root, followed, req, res) {
|
|
234
|
+
// Only pages from this server may use it: a page on some other site could
|
|
235
|
+
// otherwise reach it through a name that resolves to this machine.
|
|
236
|
+
const host = req.headers.host ?? '';
|
|
237
|
+
if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host)) {
|
|
238
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
239
|
+
res.end('reladraw serves only this machine\n');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
243
|
+
const path = decodeURIComponent(url.pathname);
|
|
244
|
+
if (path === '/.reladraw') {
|
|
245
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
246
|
+
res.end(JSON.stringify({ reladraw: true, root }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (path.startsWith('/lib/')) {
|
|
250
|
+
const name = path.slice('/lib/'.length);
|
|
251
|
+
if (!/^[a-z0-9-]+\.js$/.test(name))
|
|
252
|
+
return notFound(res, path);
|
|
253
|
+
try {
|
|
254
|
+
const code = await readFile(join(LIB, name));
|
|
255
|
+
res.writeHead(200, { 'content-type': TYPES['.js'], 'cache-control': 'no-store' });
|
|
256
|
+
res.end(code);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
notFound(res, path);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (path === '/events') {
|
|
264
|
+
const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
|
|
265
|
+
if (typeof inside !== 'string')
|
|
266
|
+
return refuse(res, inside);
|
|
267
|
+
return follow(root, followed, inside, res);
|
|
268
|
+
}
|
|
269
|
+
// What changes a file comes only from a page this server served: a page on
|
|
270
|
+
// another site sends its own origin, and is refused.
|
|
271
|
+
if (req.method === 'POST' && req.headers.origin !== undefined && req.headers.origin !== `http://${host}`) {
|
|
272
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
273
|
+
res.end('reladraw: only its own pages may change a file\n');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (path === '/save' && req.method === 'POST')
|
|
277
|
+
return save(root, followed, url, req, res);
|
|
278
|
+
if (path === '/create' && req.method === 'POST')
|
|
279
|
+
return create(root, followed, url, req, res);
|
|
280
|
+
if (path === '/note' && req.method === 'POST')
|
|
281
|
+
return note(root, followed, url, req, res);
|
|
282
|
+
if (path === '/format-settings') {
|
|
283
|
+
const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
|
|
284
|
+
if (typeof inside !== 'string')
|
|
285
|
+
return refuse(res, inside);
|
|
286
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
287
|
+
res.end(await nearestSettings(dirname(join(root, inside))));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (path === '/') {
|
|
291
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
292
|
+
res.end(playgroundPage({ examples: await examples(), folder: shown(root) }));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const inside = await insideRoot(root, path.slice(1));
|
|
296
|
+
if (typeof inside !== 'string')
|
|
297
|
+
return refuse(res, inside);
|
|
298
|
+
const text = await readFile(join(root, inside), 'utf8');
|
|
299
|
+
let html;
|
|
300
|
+
try {
|
|
301
|
+
html = drawPage(text, basename(inside), {}, new Date(), { kind: 'file', file: inside }).html;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// A file that doesn't draw yet still gets its page, drawn empty. The
|
|
305
|
+
// file's text arrives with the page's first event, fails there too, and
|
|
306
|
+
// the page shows the error, then draws as soon as the file does.
|
|
307
|
+
html = drawPage('', basename(inside), {}, new Date(), { kind: 'file', file: inside }).html;
|
|
308
|
+
}
|
|
309
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
310
|
+
res.end(html);
|
|
311
|
+
}
|
|
312
|
+
function notFound(res, path) {
|
|
313
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
314
|
+
res.end(`reladraw: nothing at ${path}\n`);
|
|
315
|
+
}
|
|
316
|
+
function refuse(res, why) {
|
|
317
|
+
res.writeHead(why.status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
318
|
+
res.end(`reladraw: ${why.problem}\n`);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Sends the file's text now and again each time it changes, as server-sent
|
|
322
|
+
* events. The folder is watched rather than the file, because an editor that
|
|
323
|
+
* saves by writing a new file and renaming it over the old one leaves a watch
|
|
324
|
+
* on the old file watching nothing.
|
|
325
|
+
*/
|
|
326
|
+
async function follow(root, followed, path, res) {
|
|
327
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
328
|
+
const one = await entryFor(root, followed, path);
|
|
329
|
+
one.listeners.add(res);
|
|
330
|
+
send(res, one);
|
|
331
|
+
const keepAlive = setInterval(() => res.write(': still here\n\n'), 25_000);
|
|
332
|
+
res.on('close', () => {
|
|
333
|
+
clearInterval(keepAlive);
|
|
334
|
+
one.listeners.delete(res);
|
|
335
|
+
});
|
|
336
|
+
if (!one.watcher) {
|
|
337
|
+
const full = join(root, path);
|
|
338
|
+
const name = basename(full);
|
|
339
|
+
one.watcher = watch(dirname(full), (_event, changed) => {
|
|
340
|
+
if (changed !== null && changed.toString() !== name)
|
|
341
|
+
return;
|
|
342
|
+
// Editors write in bursts; read once they settle.
|
|
343
|
+
if (one.timer)
|
|
344
|
+
clearTimeout(one.timer);
|
|
345
|
+
one.timer = setTimeout(() => {
|
|
346
|
+
one.timer = null;
|
|
347
|
+
readFile(full, 'utf8').then((text) => {
|
|
348
|
+
if (text === one.text)
|
|
349
|
+
return;
|
|
350
|
+
record(one, text, 'disk');
|
|
351
|
+
broadcast(one);
|
|
352
|
+
}, () => {
|
|
353
|
+
// Gone for the moment, as between an editor's delete and its
|
|
354
|
+
// rename; the next change brings it back.
|
|
355
|
+
});
|
|
356
|
+
}, 60);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The file's text, when it was read or changed and by whom, and its versions,
|
|
362
|
+
* newest last, each with its text, so the page can offer any of them back.
|
|
363
|
+
*/
|
|
364
|
+
function send(res, entry) {
|
|
365
|
+
const last = entry.versions[entry.versions.length - 1];
|
|
366
|
+
const message = {
|
|
367
|
+
text: entry.text,
|
|
368
|
+
at: entry.at,
|
|
369
|
+
changed: entry.changed,
|
|
370
|
+
who: last?.who ?? 'opened',
|
|
371
|
+
note: last?.note ?? null,
|
|
372
|
+
versions: entry.versions,
|
|
373
|
+
};
|
|
374
|
+
res.write(`event: source\ndata: ${JSON.stringify(message)}\n\n`);
|
|
375
|
+
}
|
|
376
|
+
function reply(res, status, value) {
|
|
377
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
378
|
+
res.end(JSON.stringify(value));
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* A save from the page. It carries the text the page last had from the file;
|
|
382
|
+
* if the file has changed since, the save is refused with the file as it is
|
|
383
|
+
* now, unless it says to replace it anyway, which the page asks the person
|
|
384
|
+
* first. Nothing else on the page writes the file.
|
|
385
|
+
*/
|
|
386
|
+
async function save(root, followed, url, req, res) {
|
|
387
|
+
const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
|
|
388
|
+
if (typeof inside !== 'string')
|
|
389
|
+
return refuse(res, inside);
|
|
390
|
+
const asked = JSON.parse(await body(req));
|
|
391
|
+
if (typeof asked.text !== 'string' || typeof asked.base !== 'string')
|
|
392
|
+
return reply(res, 400, { error: 'text and base are wanted' });
|
|
393
|
+
const entry = await entryFor(root, followed, inside);
|
|
394
|
+
const full = join(root, inside);
|
|
395
|
+
// Read now rather than trust the watcher, which may not have caught up.
|
|
396
|
+
const now = await readFile(full, 'utf8');
|
|
397
|
+
if (now !== entry.text)
|
|
398
|
+
record(entry, now, 'disk');
|
|
399
|
+
if (now !== asked.base && !asked.replace) {
|
|
400
|
+
const last = entry.versions[entry.versions.length - 1];
|
|
401
|
+
return reply(res, 409, { text: now, at: last.at, who: last.who, note: last.note ?? null });
|
|
402
|
+
}
|
|
403
|
+
await writeFile(full, asked.text, 'utf8');
|
|
404
|
+
record(entry, asked.text, 'you');
|
|
405
|
+
reply(res, 200, { at: entry.at });
|
|
406
|
+
broadcast(entry);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The first Save of a page with no file: a new file at a path inside the
|
|
410
|
+
* folder, typed on the page. Its folder must already be there. A file already
|
|
411
|
+
* at that path is replaced only when the page says to, which it asks the
|
|
412
|
+
* person first. From then on the page saves through `save`, as a page opened
|
|
413
|
+
* with the file does.
|
|
414
|
+
*/
|
|
415
|
+
async function create(root, followed, url, req, res) {
|
|
416
|
+
const asked = JSON.parse(await body(req));
|
|
417
|
+
if (typeof asked.text !== 'string')
|
|
418
|
+
return reply(res, 400, { error: 'text is wanted' });
|
|
419
|
+
const typed = (url.searchParams.get('file') ?? '').trim();
|
|
420
|
+
if (extname(typed) !== '.reladraw')
|
|
421
|
+
return reply(res, 400, { error: `${typed || 'The path'} isn't a .reladraw file` });
|
|
422
|
+
if (isAbsolute(typed) || typed.split(/[\\/]/).includes('..')) {
|
|
423
|
+
return reply(res, 400, { error: `Give a path inside ${shown(root)}, such as diagram.reladraw or docs/flow.reladraw` });
|
|
424
|
+
}
|
|
425
|
+
const full = resolvePath(root, typed);
|
|
426
|
+
let folder;
|
|
427
|
+
try {
|
|
428
|
+
folder = await realpath(dirname(full));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return reply(res, 400, { error: `There is no folder ${relative(root, dirname(full)).split(sep).join('/')} in ${shown(root)}` });
|
|
432
|
+
}
|
|
433
|
+
const within = relative(root, folder);
|
|
434
|
+
if (within.startsWith('..') || isAbsolute(within))
|
|
435
|
+
return reply(res, 403, { error: `That folder is outside ${shown(root)}` });
|
|
436
|
+
const there = await stat(full).then((found) => found, () => null);
|
|
437
|
+
if (there !== null) {
|
|
438
|
+
const inside = await insideRoot(root, full);
|
|
439
|
+
if (typeof inside !== 'string')
|
|
440
|
+
return reply(res, inside.status, { error: inside.problem });
|
|
441
|
+
if (!asked.replace)
|
|
442
|
+
return reply(res, 409, { exists: true, file: inside });
|
|
443
|
+
}
|
|
444
|
+
await writeFile(full, asked.text, 'utf8');
|
|
445
|
+
const inside = await insideRoot(root, full);
|
|
446
|
+
if (typeof inside !== 'string')
|
|
447
|
+
return reply(res, inside.status, { error: inside.problem });
|
|
448
|
+
const entry = followed.get(inside) ?? newEntry();
|
|
449
|
+
followed.set(inside, entry);
|
|
450
|
+
record(entry, asked.text, 'you');
|
|
451
|
+
reply(res, 200, { file: inside, at: entry.at });
|
|
452
|
+
broadcast(entry);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* `reladraw note <file> "<what changed>"`: an agent describes the change it
|
|
456
|
+
* just made. The note goes on the file's latest version, read now so a change
|
|
457
|
+
* the watcher hasn't reached yet is the one described, and marks it the agent's.
|
|
458
|
+
*/
|
|
459
|
+
async function note(root, followed, url, req, res) {
|
|
460
|
+
const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
|
|
461
|
+
if (typeof inside !== 'string')
|
|
462
|
+
return refuse(res, inside);
|
|
463
|
+
const asked = JSON.parse(await body(req));
|
|
464
|
+
if (typeof asked.note !== 'string' || asked.note.trim() === '')
|
|
465
|
+
return reply(res, 400, { error: 'a note is wanted' });
|
|
466
|
+
const entry = await entryFor(root, followed, inside);
|
|
467
|
+
const now = await readFile(join(root, inside), 'utf8');
|
|
468
|
+
if (now !== entry.text)
|
|
469
|
+
record(entry, now, 'disk');
|
|
470
|
+
const last = entry.versions[entry.versions.length - 1];
|
|
471
|
+
last.who = 'agent';
|
|
472
|
+
last.note = asked.note.trim().slice(0, 300);
|
|
473
|
+
reply(res, 200, { at: last.at });
|
|
474
|
+
broadcast(entry);
|
|
475
|
+
}
|
|
476
|
+
/** The text of the nearest .reladraw-format from this folder up, or nothing. */
|
|
477
|
+
async function nearestSettings(folder) {
|
|
478
|
+
for (let at = folder;; at = dirname(at)) {
|
|
479
|
+
try {
|
|
480
|
+
return await readFile(join(at, SETTINGS_FILE), 'utf8');
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
if (dirname(at) === at)
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const NOTE_HELP = `reladraw note <file.reladraw> "<what changed>"
|
|
489
|
+
|
|
490
|
+
Describes the change just made to the file, for the page reladraw serve shows
|
|
491
|
+
it on: the page's Versions menu lists it as the agent's, with this note. With
|
|
492
|
+
no server running for the file's folder, nothing is noted.
|
|
493
|
+
`;
|
|
494
|
+
/** `reladraw note`: run from the folder the server serves, as serve is. */
|
|
495
|
+
export async function noteCommand(args) {
|
|
496
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
497
|
+
process.stdout.write(NOTE_HELP);
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
const [file, text, ...rest] = args;
|
|
501
|
+
if (file === undefined || text === undefined || rest.length > 0 || text.trim() === '') {
|
|
502
|
+
process.stderr.write('reladraw: note takes a file and one quoted note: reladraw note diagram.reladraw "moved the cache under the API"\n');
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
const root = await realpath(process.cwd());
|
|
506
|
+
const inside = await insideRoot(root, file);
|
|
507
|
+
if (typeof inside !== 'string') {
|
|
508
|
+
process.stderr.write(`reladraw: ${inside.problem}\n`);
|
|
509
|
+
return 1;
|
|
510
|
+
}
|
|
511
|
+
for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
|
|
512
|
+
const there = await whoIsOn(port);
|
|
513
|
+
if (there?.root !== root)
|
|
514
|
+
continue;
|
|
515
|
+
const answer = await fetch(`http://127.0.0.1:${port}/note?file=${encodeURIComponent(inside)}`, {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
body: JSON.stringify({ note: text }),
|
|
518
|
+
});
|
|
519
|
+
if (answer.ok)
|
|
520
|
+
return 0;
|
|
521
|
+
process.stderr.write(`reladraw: the server didn't take the note: ${await answer.text()}\n`);
|
|
522
|
+
return 1;
|
|
523
|
+
}
|
|
524
|
+
// Not an error: an agent told to leave a note shouldn't be stopped by there
|
|
525
|
+
// being no page to show it on.
|
|
526
|
+
process.stdout.write(`reladraw: no server is serving ${root}, so nothing was noted\n`);
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
/** Opens an address in the default browser, as the skill's pages are opened. */
|
|
530
|
+
export function openInBrowser(address) {
|
|
531
|
+
const [command, args] = process.platform === 'darwin'
|
|
532
|
+
? ['open', [address]]
|
|
533
|
+
: process.platform === 'win32'
|
|
534
|
+
? ['cmd', ['/c', 'start', '""', address]]
|
|
535
|
+
: ['xdg-open', [address]];
|
|
536
|
+
try {
|
|
537
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {
|
|
538
|
+
process.stderr.write(`reladraw: couldn't open a browser; the page is at ${address}\n`);
|
|
539
|
+
}).unref();
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
process.stderr.write(`reladraw: couldn't open a browser; the page is at ${address}\n`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Transcribed from a draw.io diagram.
|
|
2
|
+
|
|
3
|
+
// --- Styles -----------------------------------------------------------------
|
|
4
|
+
style synced fill: theme-primary-subtle border: theme-primary line: theme-primary
|
|
5
|
+
style dim text: (color: theme-muted)
|
|
6
|
+
style backup border: #d2904e line: #d2904e
|
|
7
|
+
style dump fill: theme-secondary-subtle border: theme-border shape: document
|
|
8
|
+
|
|
9
|
+
// The draw.io original draws every line as a curve.
|
|
10
|
+
default edge line: (path: curved)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// --- The hub and its four machines ------------------------------------------
|
|
14
|
+
// The cluster is wrapped in an invisible container so things outside it can be
|
|
15
|
+
// placed against the whole cluster rather than against the hub alone. Each
|
|
16
|
+
// machine's title and picture are its heading, in the top-left corner, and its
|
|
17
|
+
// folder sits under them.
|
|
18
|
+
|
|
19
|
+
node cluster "" shape: none
|
|
20
|
+
|
|
21
|
+
node cluster.hub "Cloud sync" badge: package style: synced
|
|
22
|
+
|
|
23
|
+
node cluster.desktop1 "Desktop 1 / Linux" (inside top, align: left) above-left of cluster.hub (gap: tight) badge: desktop
|
|
24
|
+
node cluster.desktop1.files "shared folder / [dim]synced[/dim]" style: synced
|
|
25
|
+
|
|
26
|
+
node cluster.desktop2 "Desktop 2 / Windows" (inside top, align: left) above-right of cluster.hub (gap: tight) badge: desktop
|
|
27
|
+
node cluster.desktop2.files "shared folder / [dim]synced[/dim]" style: synced
|
|
28
|
+
|
|
29
|
+
node cluster.laptop1 "Laptop 1 / macOS" (inside top, align: left) below-left of cluster.hub (gap: tight) badge: laptop
|
|
30
|
+
node cluster.laptop1.files "shared folder / [dim]synced[/dim]" style: synced
|
|
31
|
+
|
|
32
|
+
node cluster.laptop2 "Laptop 2 / macOS" (inside top, align: left) below-right of cluster.hub (gap: tight) badge: laptop
|
|
33
|
+
node cluster.laptop2.files "shared folder / [dim]synced[/dim]" style: synced
|
|
34
|
+
|
|
35
|
+
edge cluster.desktop1.files <-> cluster.hub style: synced from: right to: top
|
|
36
|
+
edge cluster.desktop2.files <-> cluster.hub style: synced from: left to: top
|
|
37
|
+
edge cluster.laptop1.files <-> cluster.hub style: synced from: right to: bottom
|
|
38
|
+
edge cluster.laptop2.files <-> cluster.hub style: synced from: left to: bottom
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
// --- The database dumps -----------------------------------------------------
|
|
42
|
+
// The dumps sit a wide gap left of the machines, level with the Docker cubes,
|
|
43
|
+
// and the server a gap left of the dumps.
|
|
44
|
+
|
|
45
|
+
node dumps "" left of cluster (gap: wide) even with server.docker shape: none layout: column
|
|
46
|
+
node dumps.db1 "db dump / (app 1)" style: dump
|
|
47
|
+
node dumps.db2 "db dump / (app 2)" style: dump
|
|
48
|
+
|
|
49
|
+
node dumps_note "Written by nightly cron job." (wrap: 30) below dumps (gap: tight) shape: none
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
// --- The server -------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
node server "Home Server" (inside top, align: left) left of dumps even with cluster badge: desktop
|
|
55
|
+
node server.mirror "shared folder / mirror" style: backup width: server.docker
|
|
56
|
+
node server.deploy "deploy dir" below server.mirror width: server.docker
|
|
57
|
+
|
|
58
|
+
// The cubes are nodes rather than ornament: two of them are where the dump
|
|
59
|
+
// arrows start. The lower pair is centered under the upper three by naming two
|
|
60
|
+
// targets and nothing else.
|
|
61
|
+
node server.docker "Docker" (inside bottom) below server.deploy (gap: wide)
|
|
62
|
+
node server.docker.a icon: cube
|
|
63
|
+
node server.docker.b right of server.docker.a (gap: tight) icon: cube
|
|
64
|
+
node server.docker.db1 right of server.docker.b (gap: tight) icon: cube
|
|
65
|
+
node server.docker.c below server.docker.a and server.docker.b (gap: tight) icon: cube
|
|
66
|
+
node server.docker.db2 below server.docker.b and server.docker.db1 (gap: tight) icon: cube
|
|
67
|
+
|
|
68
|
+
edge server.deploy -> server.docker "compose up"
|
|
69
|
+
|
|
70
|
+
edge server.docker.db1 -> dumps.db1 from: right to: left
|
|
71
|
+
edge server.docker.db2 -> dumps.db2 from: right to: left
|
|
72
|
+
|
|
73
|
+
// The rclone lines travel together through the channel between the two
|
|
74
|
+
// left-hand machines, so the dumps arrive through the middle of the cluster
|
|
75
|
+
// rather than across one. Each rises from its dump into the bundle and runs
|
|
76
|
+
// level beside the other into the hub. Their texts sit one above the other,
|
|
77
|
+
// where a way straight down from Desktop 1 meets each.
|
|
78
|
+
guide region channel between cluster.desktop1 and cluster.laptop1
|
|
79
|
+
bundle rclone via: through channel horizontal order: one, two
|
|
80
|
+
edge one dumps.db1 -> cluster.hub "rclone" (straight down from cluster.desktop1) from: right to: left via: rclone
|
|
81
|
+
edge two dumps.db2 -> cluster.hub "rclone" (straight down from cluster.desktop1) from: right to: left via: rclone
|
|
82
|
+
|
|
83
|
+
edge cluster.desktop1.files -> server.mirror "backup script" style: backup from: left to: right
|
|
84
|
+
edge cluster.desktop1.files -> server.deploy "deploy script" from: left to: right
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
// --- The backup drive -------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
node drive "External HD" (inside top, align: left) left of server top even with server badge: disk layout: column (widths: match)
|
|
90
|
+
node drive.mirror "shared folder / backup" style: backup
|
|
91
|
+
node drive.archive "archive"
|
|
92
|
+
|
|
93
|
+
edge server.mirror -> drive.mirror style: backup from: left to: right
|
|
94
|
+
|
|
95
|
+
node drive_note "Swapped with a second drive weekly." (wrap: 22) left of drive.mirror (gap: tight) shape: none
|