@diy-accounting-uk/diya-gl 1.2.42 → 1.2.43

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 CHANGED
@@ -38,11 +38,12 @@ diya-gl recalc --package bst --data my-book --years se-2025-2026 --output-dir ou
38
38
  diya-gl read-workbook --file my-workbook.xlsx --output-dir out
39
39
  diya-gl write-workbook --data my-book --output-dir out
40
40
  diya-gl link my-book
41
+ diya-gl view my-book
41
42
  diya-gl mcp
42
43
  ```
43
44
 
44
45
  Each subcommand is also its own command, if you only want one on your `PATH`:
45
- `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-link`, `diya-gl-mcp`.
46
+ `diya-gl-recalc`, `diya-gl-read-workbook`, `diya-gl-write-workbook`, `diya-gl-link`, `diya-gl-view`, `diya-gl-mcp`.
46
47
 
47
48
  - **recalc** takes a diya-gl book (`--data`) or a populated workbook (`--source-dir`,
48
49
  `--mode saved|recalculate`) and writes `report.json`: every figure, report section and
@@ -57,6 +58,15 @@ Each subcommand is also its own command, if you only want one on your `PATH`:
57
58
  opens it, the whole book carried in the fragment so nothing is uploaded. `--base <url>`
58
59
  replaces the origin. The fragment length goes to stderr, with a warning past 64 KB (about
59
60
  2,000 lines), where chat, email and QR codes start to cut links off.
61
+ - **view** takes the same book sources as **link**, serves the full diya-gl pages on
62
+ `127.0.0.1` (`--port <number>`, default a free port) and opens the book in your default
63
+ browser (`--no-open` prints the address only). The pages are not in this package: the local
64
+ server fetches each file from https://diya-gl.co.uk on first use and caches it under the
65
+ system temporary directory, one directory per package version, so the first run needs the
66
+ network and later runs of the same version reuse the cache. `--origin <url>` fetches from
67
+ another site. The book stays in the browser (the part of the address after `#` is never sent
68
+ anywhere). The server stays up until Ctrl-C. The workbook templates come from
69
+ spreadsheets.diyaccounting.co.uk the first time a page action needs one.
60
70
  - **mcp** runs a stdio [MCP](https://modelcontextprotocol.io) server with four tools:
61
71
  `extract_book`, `report`, `edit_lines`, `save_workbook`. Point an MCP client at
62
72
  `diya-gl-mcp` (or `diya-gl mcp`) with no arguments.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // diya-gl-view.js — open a book in the full diya-gl pages, served locally
6
+ // from this package.
7
+
8
+ import { runDistBin } from "./run-dist-bin.js";
9
+ runDistBin("app/bin/view.js", process.argv.slice(2));
package/bin/diya-gl.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Copyright (C) 2006-2026 DIY Accounting Limited
4
4
  //
5
- // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|link|mcp> [args].
5
+ // diya-gl.js — the dispatcher: diya-gl <recalc|read-workbook|write-workbook|link|view|mcp> [args].
6
6
  // Each subcommand is also its own bin (diya-gl-recalc, and so on), for a
7
7
  // caller that wants one command on its PATH without the others.
8
8
 
@@ -17,6 +17,7 @@ const SUBCOMMANDS = {
17
17
  "read-workbook": "app/bin/export.js",
18
18
  "write-workbook": "app/bin/write-workbook.js",
19
19
  "link": "app/bin/link.js",
20
+ "view": "app/bin/view.js",
20
21
  "mcp": "app/bin/diya-gl-mcp.js",
21
22
  };
22
23
 
@@ -55,13 +55,17 @@ async function readBookText(source) {
55
55
  return { toml: canonicalBookToml(book), lines: canonicalLinesJsonl(lines), book };
56
56
  }
57
57
 
58
- export async function bookLink(source, base = DEFAULT_BASE) {
58
+ export async function readBookFragment(source) {
59
59
  const { toml, lines, book } = await readBookText(source);
60
60
  const declared = book.entityInformation && book.entityInformation["diya-gl:product"];
61
61
  const product = productIdOf(declared);
62
62
  if (!product)
63
63
  throw new Error(`The book declares no product with a page: entityInformation."diya-gl:product" is ${JSON.stringify(declared)}.`);
64
- const { fragment, length, warning } = encodeBookFragment({ toml, lines });
64
+ return { product, ...encodeBookFragment({ toml, lines }) };
65
+ }
66
+
67
+ export async function bookLink(source, base = DEFAULT_BASE) {
68
+ const { product, fragment, length, warning } = await readBookFragment(source);
65
69
  return { url: `${base}/${product}.html#${FRAGMENT_KEY}=${fragment}`, length, warning };
66
70
  }
67
71
 
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright (C) 2006-2026 DIY Accounting Limited
4
+ //
5
+ // view.js — open a book in the full diya-gl pages, served on 127.0.0.1 by a
6
+ // caching proxy that fetches each page file from diya-gl.co.uk on first use.
7
+ //
8
+ // Usage:
9
+ // node app/bin/view.js examples/basic-taxi-driver
10
+ // node app/bin/view.js my-book-diya-gl.zip --port 8080 --no-open
11
+ //
12
+ // The book is any source link.js reads: a directory holding book.toml and
13
+ // lines.jsonl, or a diya-gl zip, JSON or workbook file. The server stays up
14
+ // until Ctrl-C. Fetched files are cached under the system temporary directory,
15
+ // one directory per package version and origin host.
16
+
17
+ import { spawn } from "child_process";
18
+ import { existsSync, readFileSync } from "fs";
19
+ import { tmpdir } from "os";
20
+ import { dirname, join, resolve } from "path";
21
+ import { fileURLToPath } from "url";
22
+ import { readBookFragment } from "./link.js";
23
+ import { FRAGMENT_KEY } from "../lib/diya-gl-link.js";
24
+ import { DEFAULT_ORIGIN, startViewServer } from "../lib/diya-gl-view-server.js";
25
+
26
+ const USAGE =
27
+ "Usage: diya-gl view <book directory | diya-gl zip | diya-gl JSON file | workbook> [--port <number>] [--origin <url>] [--no-open]";
28
+
29
+ // The nearest package.json above this file: the package's own in dist/, the
30
+ // repository's in a checkout. Both carry the version the cache is keyed on.
31
+ function packageVersion() {
32
+ let directory = dirname(fileURLToPath(import.meta.url));
33
+ for (;;) {
34
+ const candidate = join(directory, "package.json");
35
+ if (existsSync(candidate)) return JSON.parse(readFileSync(candidate, "utf8")).version;
36
+ const parent = dirname(directory);
37
+ if (parent === directory) throw new Error("No package.json found above " + import.meta.url);
38
+ directory = parent;
39
+ }
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const args = argv.slice(2);
44
+ let port = 0;
45
+ let origin = DEFAULT_ORIGIN;
46
+ let open = true;
47
+ const positional = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ if (args[i] === "--port") {
50
+ const value = args[++i];
51
+ port = Number(value);
52
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
53
+ throw new Error(`--port needs a number from 0 to 65535, got "${value}". ${USAGE}`);
54
+ } else if (args[i] === "--origin") {
55
+ origin = args[++i];
56
+ let parsed;
57
+ try {
58
+ parsed = new URL(origin);
59
+ } catch {
60
+ throw new Error(`--origin needs an http or https URL, got "${origin}". ${USAGE}`);
61
+ }
62
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
63
+ throw new Error(`--origin needs an http or https URL, got "${origin}". ${USAGE}`);
64
+ } else if (args[i] === "--no-open") {
65
+ open = false;
66
+ } else {
67
+ positional.push(args[i]);
68
+ }
69
+ }
70
+ if (positional.length !== 1) throw new Error(USAGE);
71
+ return { source: positional[0], port, origin, open };
72
+ }
73
+
74
+ function openInBrowser(url) {
75
+ const [command, args] =
76
+ process.platform === "darwin"
77
+ ? ["open", [url]]
78
+ : process.platform === "win32"
79
+ ? ["cmd", ["/c", "start", "", url]]
80
+ : ["xdg-open", [url]];
81
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
82
+ child.on("error", (error) => console.error(`Could not open a browser (${error.message}). Open the address above.`));
83
+ child.unref();
84
+ }
85
+
86
+ async function main() {
87
+ const { source, port, origin, open } = parseArgs(process.argv);
88
+ const { product, fragment, length, warning } = await readBookFragment(source);
89
+ const cacheDir = join(tmpdir(), "diya-gl-view", packageVersion(), new URL(origin).host.replace(/[^A-Za-z0-9.-]/g, "_"));
90
+ const running = await startViewServer({ cacheDir, origin, port });
91
+ const url = `http://${running.host}:${running.port}/${product}.html#${FRAGMENT_KEY}=${fragment}`;
92
+ console.log(url);
93
+ console.error(`Fragment length: ${length} characters`);
94
+ if (warning) console.error(`Warning: ${warning}`);
95
+ console.error(`Fetching pages from ${running.origin} on first use.`);
96
+ console.error(`Cache directory: ${running.cacheDir}`);
97
+ console.error("Serving on 127.0.0.1 until you press Ctrl-C.");
98
+ if (open) openInBrowser(url);
99
+
100
+ process.on("SIGINT", () => {
101
+ running.close().then(() => process.exit(0));
102
+ });
103
+ }
104
+
105
+ main().catch((error) => {
106
+ console.error(error.message);
107
+ process.exit(1);
108
+ });
@@ -1325,6 +1325,32 @@
1325
1325
  "scorecard": "101605 passed, 0 warnings, 0 failed"
1326
1326
  }
1327
1327
  }
1328
+ },
1329
+ {
1330
+ "tag": "diya-gl-v1.2.42",
1331
+ "date": "2026-10-03",
1332
+ "formatVersion": "diya-gl/1",
1333
+ "engineVersion": "1.2.40+57de8f55b",
1334
+ "taxDataHash": "89424bd36c0d",
1335
+ "reconciledCommit": "54e8731a91279975e61b1a232fbb67fda89515f0",
1336
+ "templates": {
1337
+ "bst": {
1338
+ "hash": "0d003e10e00c",
1339
+ "scorecard": "812 passed, 0 warnings, 0 failed"
1340
+ },
1341
+ "taxi": {
1342
+ "hash": "1ff69654d64d",
1343
+ "scorecard": "700 passed, 0 warnings, 0 failed"
1344
+ },
1345
+ "se": {
1346
+ "hash": "064605ae76ed",
1347
+ "scorecard": "8014 passed, 0 warnings, 0 failed"
1348
+ },
1349
+ "ltd": {
1350
+ "hash": "5bb7be9b6d97",
1351
+ "scorecard": "101605 passed, 0 warnings, 0 failed"
1352
+ }
1353
+ }
1328
1354
  }
1329
1355
  ]
1330
1356
  }
@@ -0,0 +1,203 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2006-2026 DIY Accounting Limited
3
+ //
4
+ // diya-gl-view-server.js — a read-only caching proxy on 127.0.0.1 for the
5
+ // diya-gl pages. A GET or HEAD for /<path> is answered from the cache
6
+ // directory when it holds the file; otherwise the file is fetched from the
7
+ // origin site, stored with its content type, and served. A non-2xx answer
8
+ // from the origin passes through with its status and is not stored.
9
+ //
10
+ // The pages are not part of the npm package: they are fetched from the site
11
+ // on first use. The service worker script is answered 404, so the browser
12
+ // never layers a second cache over this one.
13
+ //
14
+ // The workbook templates are fetched from the template source (the same site
15
+ // the engine's own template loader uses) and streamed back unchanged.
16
+
17
+ import { createServer } from "http";
18
+ import { mkdir, readFile, realpath, rename, writeFile } from "fs/promises";
19
+ import { extname, resolve, sep } from "path";
20
+
21
+ export const DEFAULT_ORIGIN = "https://diya-gl.co.uk";
22
+ export const DEFAULT_TEMPLATE_SOURCE = "https://spreadsheets.diyaccounting.co.uk/diya-gl/assets";
23
+ const TEMPLATE_URL_PREFIX = "/assets/templates/";
24
+ // A template is "meta.toml" or "<product>/<file>" with a workbook or TOML extension; nothing else
25
+ // is proxied, so the upstream URL is always the template source plus one of these names.
26
+ const TEMPLATE_NAME = /^(?:[a-z]+\/)?[A-Za-z0-9][A-Za-z0-9._-]*\.(?:xlsx|toml)$/;
27
+
28
+ const CONTENT_TYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".js": "text/javascript; charset=utf-8",
31
+ ".mjs": "text/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".json": "application/json; charset=utf-8",
34
+ ".jsonl": "application/x-ndjson; charset=utf-8",
35
+ ".toml": "text/plain; charset=utf-8",
36
+ ".txt": "text/plain; charset=utf-8",
37
+ ".svg": "image/svg+xml",
38
+ ".png": "image/png",
39
+ ".ico": "image/x-icon",
40
+ ".webmanifest": "application/manifest+json",
41
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
42
+ ".zip": "application/zip",
43
+ };
44
+
45
+ function send(response, status, body, contentType = "text/plain; charset=utf-8") {
46
+ response.writeHead(status, { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
47
+ response.end(body);
48
+ }
49
+
50
+ const SERVICE_WORKER_PATH = "/sw.js";
51
+
52
+ /**
53
+ * The cache entry paths for a URL path, or null when the path would leave the
54
+ * cache directory. The body and its content type are stored side by side.
55
+ */
56
+ function cacheEntryFor(cacheReal, urlPath) {
57
+ let decoded;
58
+ try {
59
+ decoded = decodeURIComponent(urlPath);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (decoded.includes("\0") || decoded.includes("\\")) return null;
64
+ const segments = decoded.split("/").filter((segment) => segment !== "");
65
+ if (segments.some((segment) => segment === "." || segment === "..")) return null;
66
+ if (decoded.endsWith("/") || segments.length === 0) segments.push("index.html");
67
+ const body = resolve(cacheReal, "body", ...segments);
68
+ if (!body.startsWith(resolve(cacheReal, "body") + sep)) return null;
69
+ return { body, type: resolve(cacheReal, "type", ...segments) };
70
+ }
71
+
72
+ async function readIfPresent(path, cacheReal) {
73
+ try {
74
+ const real = await realpath(path);
75
+ if (!real.startsWith(cacheReal + sep)) return null;
76
+ return await readFile(real);
77
+ } catch (error) {
78
+ if (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "EISDIR") return null;
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ async function writeAtomically(path, data) {
84
+ await mkdir(resolve(path, ".."), { recursive: true });
85
+ const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
86
+ await writeFile(temporary, data);
87
+ await rename(temporary, path);
88
+ }
89
+
90
+ async function serveFromOrigin(request, response, { origin, cacheReal, urlPath, search }) {
91
+ const entry = cacheEntryFor(cacheReal, urlPath);
92
+ if (!entry) {
93
+ send(response, 404, "Not found\n");
94
+ return;
95
+ }
96
+ const cachedBody = await readIfPresent(entry.body, cacheReal);
97
+ const cachedType = cachedBody ? await readIfPresent(entry.type, cacheReal) : null;
98
+ if (cachedBody && cachedType) {
99
+ response.writeHead(200, {
100
+ "Content-Type": cachedType.toString("utf8"),
101
+ "Cache-Control": "no-store",
102
+ "X-Content-Type-Options": "nosniff",
103
+ });
104
+ response.end(request.method === "HEAD" ? undefined : cachedBody);
105
+ return;
106
+ }
107
+ const url = `${origin}${urlPath}${search}`;
108
+ let upstream;
109
+ try {
110
+ upstream = await fetch(url);
111
+ } catch (cause) {
112
+ send(response, 502, `${url} could not be fetched: ${cause.message}\n`);
113
+ return;
114
+ }
115
+ const body = Buffer.from(await upstream.arrayBuffer());
116
+ const contentType = upstream.headers.get("content-type") ?? "application/octet-stream";
117
+ if (!upstream.ok) {
118
+ send(response, upstream.status, request.method === "HEAD" ? undefined : body, contentType);
119
+ return;
120
+ }
121
+ await writeAtomically(entry.body, body);
122
+ await writeAtomically(entry.type, contentType);
123
+ send(response, 200, request.method === "HEAD" ? undefined : body, contentType);
124
+ }
125
+
126
+ async function proxyTemplate(response, templateSource, urlPath) {
127
+ const requested = urlPath.slice(TEMPLATE_URL_PREFIX.length);
128
+ const base = `${templateSource.replace(/\/$/, "")}/templates/`;
129
+ const url = TEMPLATE_NAME.test(requested) ? new URL(requested, base) : null;
130
+ if (!url || url.origin !== new URL(base).origin || !url.href.startsWith(base)) {
131
+ send(response, 404, "Not found\n");
132
+ return;
133
+ }
134
+ let upstream;
135
+ try {
136
+ upstream = await fetch(url);
137
+ } catch (cause) {
138
+ send(response, 502, `The workbook template could not be fetched from ${url.href}: ${cause.message}\n`);
139
+ return;
140
+ }
141
+ if (!upstream.ok) {
142
+ send(response, upstream.status === 404 ? 404 : 502, `${url.href} returned ${upstream.status}\n`);
143
+ return;
144
+ }
145
+ send(response, 200, Buffer.from(await upstream.arrayBuffer()), CONTENT_TYPES[extname(url.pathname)] ?? "application/octet-stream");
146
+ }
147
+
148
+ /**
149
+ * @param {Object} options
150
+ * @param {string} options.cacheDir - the directory fetched files are stored in; created when absent
151
+ * @param {string} [options.origin] - the site pages are fetched from
152
+ * @param {number} [options.port] - 0 or absent lets the OS choose a free port
153
+ * @param {string} [options.templateSource] - where /assets/templates/ requests are fetched from
154
+ * @returns {Promise<{server: import("http").Server, port: number, host: string, origin: string, cacheDir: string, close: () => Promise<void>}>}
155
+ */
156
+ export async function startViewServer({ cacheDir, origin = DEFAULT_ORIGIN, port = 0, templateSource = DEFAULT_TEMPLATE_SOURCE }) {
157
+ await mkdir(cacheDir, { recursive: true });
158
+ const cacheReal = await realpath(cacheDir);
159
+ const host = "127.0.0.1";
160
+ const originBase = origin.replace(/\/$/, "");
161
+
162
+ const server = createServer(async (request, response) => {
163
+ try {
164
+ if (request.method !== "GET" && request.method !== "HEAD") {
165
+ send(response, 405, "Method not allowed\n");
166
+ return;
167
+ }
168
+ // The path as sent: new URL() would collapse dot segments before the escape check sees them.
169
+ const queryAt = request.url.indexOf("?");
170
+ const urlPath = queryAt === -1 ? request.url : request.url.slice(0, queryAt);
171
+ const search = queryAt === -1 ? "" : request.url.slice(queryAt);
172
+ if (!urlPath.startsWith("/") || urlPath === SERVICE_WORKER_PATH) {
173
+ send(response, 404, "Not found\n");
174
+ return;
175
+ }
176
+ if (urlPath.startsWith(TEMPLATE_URL_PREFIX)) {
177
+ await proxyTemplate(response, templateSource, urlPath);
178
+ return;
179
+ }
180
+ await serveFromOrigin(request, response, { origin: originBase, cacheReal, urlPath, search });
181
+ } catch (error) {
182
+ send(response, 500, `${error.message}\n`);
183
+ }
184
+ });
185
+
186
+ await new Promise((resolveListening, rejectListening) => {
187
+ server.once("error", rejectListening);
188
+ server.listen(port, host, resolveListening);
189
+ });
190
+
191
+ return {
192
+ server,
193
+ host,
194
+ origin: originBase,
195
+ cacheDir: cacheReal,
196
+ port: server.address().port,
197
+ close: () =>
198
+ new Promise((resolveClosed) => {
199
+ server.close(() => resolveClosed());
200
+ server.closeAllConnections();
201
+ }),
202
+ };
203
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diy-accounting-uk/diya-gl",
3
- "version": "1.2.42",
3
+ "version": "1.2.43",
4
4
  "type": "module",
5
5
  "description": "Recalculate, read and write DIYA-GL books from the command line",
6
6
  "license": "Apache-2.0",
@@ -17,6 +17,7 @@
17
17
  "diya-gl-read-workbook": "bin/diya-gl-read-workbook.js",
18
18
  "diya-gl-write-workbook": "bin/diya-gl-write-workbook.js",
19
19
  "diya-gl-link": "bin/diya-gl-link.js",
20
+ "diya-gl-view": "bin/diya-gl-view.js",
20
21
  "diya-gl-mcp": "bin/diya-gl-mcp.js"
21
22
  },
22
23
  "files": ["bin/", "dist/", "README.md", "NOTICE"],