@deskwork/studio 0.9.5
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/dist/build-client-assets.d.ts +51 -0
- package/dist/build-client-assets.d.ts.map +1 -0
- package/dist/build-client-assets.js +341 -0
- package/dist/build-client-assets.js.map +1 -0
- package/dist/components/scrapbook-item.d.ts +108 -0
- package/dist/components/scrapbook-item.d.ts.map +1 -0
- package/dist/components/scrapbook-item.js +205 -0
- package/dist/components/scrapbook-item.js.map +1 -0
- package/dist/lib/editorial-skills-catalogue.d.ts +33 -0
- package/dist/lib/editorial-skills-catalogue.d.ts.map +1 -0
- package/dist/lib/editorial-skills-catalogue.js +211 -0
- package/dist/lib/editorial-skills-catalogue.js.map +1 -0
- package/dist/lib/override-render.d.ts +41 -0
- package/dist/lib/override-render.d.ts.map +1 -0
- package/dist/lib/override-render.js +80 -0
- package/dist/lib/override-render.js.map +1 -0
- package/dist/listen.d.ts +78 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +155 -0
- package/dist/listen.js.map +1 -0
- package/dist/pages/chrome.d.ts +26 -0
- package/dist/pages/chrome.d.ts.map +1 -0
- package/dist/pages/chrome.js +50 -0
- package/dist/pages/chrome.js.map +1 -0
- package/dist/pages/content-detail.d.ts +14 -0
- package/dist/pages/content-detail.d.ts.map +1 -0
- package/dist/pages/content-detail.js +279 -0
- package/dist/pages/content-detail.js.map +1 -0
- package/dist/pages/content.d.ts +39 -0
- package/dist/pages/content.d.ts.map +1 -0
- package/dist/pages/content.js +414 -0
- package/dist/pages/content.js.map +1 -0
- package/dist/pages/dashboard.d.ts +32 -0
- package/dist/pages/dashboard.d.ts.map +1 -0
- package/dist/pages/dashboard.js +803 -0
- package/dist/pages/dashboard.js.map +1 -0
- package/dist/pages/help.d.ts +24 -0
- package/dist/pages/help.d.ts.map +1 -0
- package/dist/pages/help.js +433 -0
- package/dist/pages/help.js.map +1 -0
- package/dist/pages/html.d.ts +35 -0
- package/dist/pages/html.d.ts.map +1 -0
- package/dist/pages/html.js +73 -0
- package/dist/pages/html.js.map +1 -0
- package/dist/pages/index.d.ts +21 -0
- package/dist/pages/index.d.ts.map +1 -0
- package/dist/pages/index.js +174 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/layout.d.ts +33 -0
- package/dist/pages/layout.d.ts.map +1 -0
- package/dist/pages/layout.js +50 -0
- package/dist/pages/layout.js.map +1 -0
- package/dist/pages/review-scrapbook-drawer.d.ts +20 -0
- package/dist/pages/review-scrapbook-drawer.d.ts.map +1 -0
- package/dist/pages/review-scrapbook-drawer.js +98 -0
- package/dist/pages/review-scrapbook-drawer.js.map +1 -0
- package/dist/pages/review.d.ts +68 -0
- package/dist/pages/review.d.ts.map +1 -0
- package/dist/pages/review.js +434 -0
- package/dist/pages/review.js.map +1 -0
- package/dist/pages/scrapbook.d.ts +21 -0
- package/dist/pages/scrapbook.d.ts.map +1 -0
- package/dist/pages/scrapbook.js +250 -0
- package/dist/pages/scrapbook.js.map +1 -0
- package/dist/pages/shortform.d.ts +17 -0
- package/dist/pages/shortform.d.ts.map +1 -0
- package/dist/pages/shortform.js +142 -0
- package/dist/pages/shortform.js.map +1 -0
- package/dist/request-context.d.ts +52 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +84 -0
- package/dist/request-context.js.map +1 -0
- package/dist/routes/api.d.ts +36 -0
- package/dist/routes/api.d.ts.map +1 -0
- package/dist/routes/api.js +175 -0
- package/dist/routes/api.js.map +1 -0
- package/dist/routes/scrapbook-file.d.ts +19 -0
- package/dist/routes/scrapbook-file.d.ts.map +1 -0
- package/dist/routes/scrapbook-file.js +77 -0
- package/dist/routes/scrapbook-file.js.map +1 -0
- package/dist/routes/scrapbook-mutations.d.ts +33 -0
- package/dist/routes/scrapbook-mutations.d.ts.map +1 -0
- package/dist/routes/scrapbook-mutations.js +310 -0
- package/dist/routes/scrapbook-mutations.js.map +1 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +581 -0
- package/dist/server.js.map +1 -0
- package/dist/tailscale.d.ts +63 -0
- package/dist/tailscale.d.ts.map +1 -0
- package/dist/tailscale.js +118 -0
- package/dist/tailscale.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API route handlers for the studio. Each route is a thin wrapper over a
|
|
3
|
+
* `lib/review/handlers.ts` function — it parses the HTTP request, calls
|
|
4
|
+
* the handler with `(projectRoot, config, body)`, and serializes the
|
|
5
|
+
* `{status, body}` result as JSON.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the audiocontrol Astro routes byte-for-byte so the existing
|
|
8
|
+
* client code (editorial-review-client.ts, editorial-studio-client.ts)
|
|
9
|
+
* keeps working without changes.
|
|
10
|
+
*/
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { handleAnnotate, handleListAnnotations, handleDecision, handleGetWorkflow, handleCreateVersion, handleStartLongform, } from '@deskwork/core/review/handlers';
|
|
13
|
+
import { handleStartShortform } from '@deskwork/core/review/start-handlers';
|
|
14
|
+
import { renderMarkdownToHtml } from '@deskwork/core/review/render';
|
|
15
|
+
/**
|
|
16
|
+
* Narrow a `HandlerResult.body` (typed as `unknown`) to extract the
|
|
17
|
+
* workflow id. Returns null when the shape doesn't match — phase 21c's
|
|
18
|
+
* start-shortform route uses this to decide whether to augment the
|
|
19
|
+
* response with a reviewUrl. Avoids `as`-casts on the body.
|
|
20
|
+
*/
|
|
21
|
+
function extractWorkflowId(body) {
|
|
22
|
+
if (typeof body !== 'object' || body === null)
|
|
23
|
+
return null;
|
|
24
|
+
const workflow = Reflect.get(body, 'workflow');
|
|
25
|
+
if (typeof workflow !== 'object' || workflow === null)
|
|
26
|
+
return null;
|
|
27
|
+
const id = Reflect.get(workflow, 'id');
|
|
28
|
+
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build a fresh response object that preserves every field from the
|
|
32
|
+
* original handler body and adds `reviewUrl`. Iterates own keys via
|
|
33
|
+
* `Object.entries` so the result is a plain `Record<string, unknown>`
|
|
34
|
+
* rather than carrying the input's unknown-typed shape.
|
|
35
|
+
*/
|
|
36
|
+
function withReviewUrl(body, workflowId) {
|
|
37
|
+
const out = {};
|
|
38
|
+
if (typeof body === 'object' && body !== null) {
|
|
39
|
+
for (const [k, v] of Object.entries(body)) {
|
|
40
|
+
out[k] = v;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
out.reviewUrl = `/dev/editorial-review/${workflowId}`;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function createApiRouter(ctx) {
|
|
47
|
+
const app = new Hono();
|
|
48
|
+
// POST /api/dev/editorial-review/annotate
|
|
49
|
+
app.post('/annotate', async (c) => {
|
|
50
|
+
let body;
|
|
51
|
+
try {
|
|
52
|
+
body = await c.req.json();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
56
|
+
}
|
|
57
|
+
const r = handleAnnotate(ctx.projectRoot, ctx.config, body);
|
|
58
|
+
return c.json(r.body, r.status);
|
|
59
|
+
});
|
|
60
|
+
// GET /api/dev/editorial-review/annotations?workflowId=...&version=...
|
|
61
|
+
app.get('/annotations', (c) => {
|
|
62
|
+
const r = handleListAnnotations(ctx.projectRoot, ctx.config, {
|
|
63
|
+
workflowId: c.req.query('workflowId') ?? null,
|
|
64
|
+
version: c.req.query('version') ?? null,
|
|
65
|
+
});
|
|
66
|
+
return c.json(r.body, r.status);
|
|
67
|
+
});
|
|
68
|
+
// POST /api/dev/editorial-review/decision
|
|
69
|
+
app.post('/decision', async (c) => {
|
|
70
|
+
let body;
|
|
71
|
+
try {
|
|
72
|
+
body = await c.req.json();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
76
|
+
}
|
|
77
|
+
const r = handleDecision(ctx.projectRoot, ctx.config, body);
|
|
78
|
+
return c.json(r.body, r.status);
|
|
79
|
+
});
|
|
80
|
+
// GET /api/dev/editorial-review/workflow?id=... or ?site=...&slug=...&...
|
|
81
|
+
app.get('/workflow', (c) => {
|
|
82
|
+
const r = handleGetWorkflow(ctx.projectRoot, ctx.config, {
|
|
83
|
+
id: c.req.query('id') ?? null,
|
|
84
|
+
site: c.req.query('site') ?? null,
|
|
85
|
+
slug: c.req.query('slug') ?? null,
|
|
86
|
+
contentKind: c.req.query('contentKind') ?? null,
|
|
87
|
+
platform: c.req.query('platform') ?? null,
|
|
88
|
+
channel: c.req.query('channel') ?? null,
|
|
89
|
+
});
|
|
90
|
+
return c.json(r.body, r.status);
|
|
91
|
+
});
|
|
92
|
+
// POST /api/dev/editorial-review/version
|
|
93
|
+
app.post('/version', async (c) => {
|
|
94
|
+
let body;
|
|
95
|
+
try {
|
|
96
|
+
body = await c.req.json();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
100
|
+
}
|
|
101
|
+
const r = handleCreateVersion(ctx.projectRoot, ctx.config, body);
|
|
102
|
+
return c.json(r.body, r.status);
|
|
103
|
+
});
|
|
104
|
+
// POST /api/dev/editorial-review/start-longform
|
|
105
|
+
app.post('/start-longform', async (c) => {
|
|
106
|
+
let body;
|
|
107
|
+
try {
|
|
108
|
+
body = await c.req.json();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
112
|
+
}
|
|
113
|
+
const r = handleStartLongform(ctx.projectRoot, ctx.config, body);
|
|
114
|
+
return c.json(r.body, r.status);
|
|
115
|
+
});
|
|
116
|
+
// POST /api/dev/editorial-review/start-shortform
|
|
117
|
+
//
|
|
118
|
+
// Phase 21c: Mirrors start-longform but for shortform workflows.
|
|
119
|
+
// Augments the handler's success body with a `reviewUrl` so the
|
|
120
|
+
// dashboard's matrix-cell start button can fetch + redirect in one
|
|
121
|
+
// round-trip. The handler scaffolds the disk file (frontmatter +
|
|
122
|
+
// initial body) when missing and is idempotent on
|
|
123
|
+
// (entryId|site+slug, contentKind, platform, channel).
|
|
124
|
+
app.post('/start-shortform', async (c) => {
|
|
125
|
+
let body;
|
|
126
|
+
try {
|
|
127
|
+
body = await c.req.json();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
131
|
+
}
|
|
132
|
+
const r = handleStartShortform(ctx.projectRoot, ctx.config, body);
|
|
133
|
+
const workflowId = extractWorkflowId(r.body);
|
|
134
|
+
if (r.status === 200 && workflowId !== null) {
|
|
135
|
+
const augmented = withReviewUrl(r.body, workflowId);
|
|
136
|
+
return c.json(augmented, 200);
|
|
137
|
+
}
|
|
138
|
+
return c.json(r.body, r.status);
|
|
139
|
+
});
|
|
140
|
+
// POST /api/dev/editorial-review/render
|
|
141
|
+
//
|
|
142
|
+
// Replaces the upstream client-side dynamic import of
|
|
143
|
+
// `scripts/lib/editorial-review/render.js`. The preview pane in
|
|
144
|
+
// `editorial-review-client.ts` POSTs the markdown source here and
|
|
145
|
+
// gets HTML back. Centralising the render keeps the unified pipeline
|
|
146
|
+
// (remark-parse + remark-strip-first-h1 + remark-image-figure +
|
|
147
|
+
// remark-rehype + rehype-stringify) on the server, where it already
|
|
148
|
+
// runs for the initial review-page render — one source of truth.
|
|
149
|
+
app.post('/render', async (c) => {
|
|
150
|
+
let body;
|
|
151
|
+
try {
|
|
152
|
+
body = await c.req.json();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return c.json({ error: 'invalid JSON body' }, 400);
|
|
156
|
+
}
|
|
157
|
+
if (!body || typeof body !== 'object') {
|
|
158
|
+
return c.json({ error: 'expected JSON object body' }, 400);
|
|
159
|
+
}
|
|
160
|
+
const markdown = body.markdown;
|
|
161
|
+
if (typeof markdown !== 'string') {
|
|
162
|
+
return c.json({ error: 'markdown (string) is required' }, 400);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const html = await renderMarkdownToHtml(markdown);
|
|
166
|
+
return c.json({ html });
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
170
|
+
return c.json({ error: `render failed: ${reason}` }, 500);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return app;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/routes/api.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only binary endpoint for scrapbook files.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/dev/scrapbook-file?site=<slug>&path=<scrapbook path>&name=<filename>[&secret=1]`
|
|
5
|
+
*
|
|
6
|
+
* Returns the raw bytes of a single scrapbook file with a sensible
|
|
7
|
+
* Content-Type header. Read-only — no write/rename/delete here. The
|
|
8
|
+
* shared scrapbook-item renderer uses this for image thumbnails,
|
|
9
|
+
* PDF iframes, and download links on the review-drawer + content-view
|
|
10
|
+
* surfaces.
|
|
11
|
+
*
|
|
12
|
+
* Validation runs through `@deskwork/core/scrapbook`'s own
|
|
13
|
+
* `assertSlug` / `assertFilename` (via `readScrapbookFile`), so path
|
|
14
|
+
* traversal attempts are caught at the core boundary, not here.
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from 'hono';
|
|
17
|
+
import type { StudioContext } from './api.ts';
|
|
18
|
+
export declare function serveScrapbookFile(c: Context, ctx: StudioContext): Promise<Response>;
|
|
19
|
+
//# sourceMappingURL=scrapbook-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrapbook-file.d.ts","sourceRoot":"","sources":["../../src/routes/scrapbook-file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAwB9C,wBAAsB,kBAAkB,CACtC,CAAC,EAAE,OAAO,EACV,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,QAAQ,CAAC,CA2CnB"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only binary endpoint for scrapbook files.
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/dev/scrapbook-file?site=<slug>&path=<scrapbook path>&name=<filename>[&secret=1]`
|
|
5
|
+
*
|
|
6
|
+
* Returns the raw bytes of a single scrapbook file with a sensible
|
|
7
|
+
* Content-Type header. Read-only — no write/rename/delete here. The
|
|
8
|
+
* shared scrapbook-item renderer uses this for image thumbnails,
|
|
9
|
+
* PDF iframes, and download links on the review-drawer + content-view
|
|
10
|
+
* surfaces.
|
|
11
|
+
*
|
|
12
|
+
* Validation runs through `@deskwork/core/scrapbook`'s own
|
|
13
|
+
* `assertSlug` / `assertFilename` (via `readScrapbookFile`), so path
|
|
14
|
+
* traversal attempts are caught at the core boundary, not here.
|
|
15
|
+
*/
|
|
16
|
+
import { extname } from 'node:path';
|
|
17
|
+
import { readScrapbookFile } from '@deskwork/core/scrapbook';
|
|
18
|
+
const MIME_TYPES = {
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.webp': 'image/webp',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.pdf': 'application/pdf',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.jsonl': 'application/jsonl; charset=utf-8',
|
|
28
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
29
|
+
'.log': 'text/plain; charset=utf-8',
|
|
30
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
31
|
+
'.markdown': 'text/markdown; charset=utf-8',
|
|
32
|
+
'.mdx': 'text/markdown; charset=utf-8',
|
|
33
|
+
};
|
|
34
|
+
function contentTypeFor(filename) {
|
|
35
|
+
const ext = extname(filename).toLowerCase();
|
|
36
|
+
return MIME_TYPES[ext] ?? 'application/octet-stream';
|
|
37
|
+
}
|
|
38
|
+
export async function serveScrapbookFile(c, ctx) {
|
|
39
|
+
const site = c.req.query('site');
|
|
40
|
+
const path = c.req.query('path');
|
|
41
|
+
const name = c.req.query('name');
|
|
42
|
+
const secret = c.req.query('secret') === '1';
|
|
43
|
+
if (!site || !path || !name) {
|
|
44
|
+
return c.json({ error: 'site, path, and name query params are required' }, 400);
|
|
45
|
+
}
|
|
46
|
+
if (!(site in ctx.config.sites)) {
|
|
47
|
+
return c.json({ error: `unknown site: ${site}` }, 404);
|
|
48
|
+
}
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
result = readScrapbookFile(ctx.projectRoot, ctx.config, site, path, name, {
|
|
52
|
+
secret,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
57
|
+
// The core helper throws on invalid slug / invalid filename / file
|
|
58
|
+
// not found / path traversal. Treat all of them as 404 from the
|
|
59
|
+
// operator's perspective — the read-only endpoint shouldn't
|
|
60
|
+
// distinguish "doesn't exist" from "off-limits".
|
|
61
|
+
return c.json({ error: reason }, 404);
|
|
62
|
+
}
|
|
63
|
+
// Hono's c.body() expects a BodyInit. Node Buffers are typed as
|
|
64
|
+
// `Uint8Array<ArrayBufferLike>` but Hono's overload demands
|
|
65
|
+
// `Uint8Array<ArrayBuffer>` — copy into a fresh buffer to satisfy
|
|
66
|
+
// the type without bypassing typing. Scrapbook files are small
|
|
67
|
+
// (the operator put them there by hand) so the copy cost is fine.
|
|
68
|
+
const src = result.content;
|
|
69
|
+
const copy = new Uint8Array(src.byteLength);
|
|
70
|
+
copy.set(src);
|
|
71
|
+
return c.body(copy, 200, {
|
|
72
|
+
'Content-Type': contentTypeFor(result.name),
|
|
73
|
+
'Content-Length': String(result.size),
|
|
74
|
+
'Cache-Control': 'private, max-age=10',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=scrapbook-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrapbook-file.js","sourceRoot":"","sources":["../../src/routes/scrapbook-file.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation endpoints for the standalone scrapbook viewer
|
|
3
|
+
* (`/dev/scrapbook/<site>/<path>`). Five POST endpoints — save, rename,
|
|
4
|
+
* delete, create, upload — that the client at
|
|
5
|
+
* `plugins/deskwork-studio/public/src/scrapbook-client.ts` calls when
|
|
6
|
+
* the operator edits a slip, renames it, deletes it, drafts a new note,
|
|
7
|
+
* or drops a file onto the page.
|
|
8
|
+
*
|
|
9
|
+
* Phase 13 ported the client chrome but never landed these routes;
|
|
10
|
+
* Phase 16d retrofitted only the read path. v0.4.1 closes the gap
|
|
11
|
+
* (issue #21).
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - Path resolution + traversal protection runs through
|
|
15
|
+
* `@deskwork/core/scrapbook` helpers (`scrapbookFilePath` etc.).
|
|
16
|
+
* Those throw on `..` sequences, absolute paths, and any filename
|
|
17
|
+
* that escapes the scrapbook dir; we surface those as 400.
|
|
18
|
+
* - The client speaks `{ site, slug, filename, body }` for save/create,
|
|
19
|
+
* `{ site, slug, oldName, newName }` for rename, `{ site, slug,
|
|
20
|
+
* filename }` for delete, multipart `{ site, slug, file }` for
|
|
21
|
+
* upload. Endpoints accept those exact field names.
|
|
22
|
+
* - We never log file contents on save / upload (privacy). Logging
|
|
23
|
+
* filename + slug is fine but kept silent here — the studio's
|
|
24
|
+
* console is the operator's, not a server log.
|
|
25
|
+
* - Errors from the core helpers carry text like `"file not found:
|
|
26
|
+
* <name>"`, `"file already exists: <name>"`, or `"resolved path
|
|
27
|
+
* escapes scrapbook dir"`. We map those onto the right HTTP code
|
|
28
|
+
* so the client UI can flash a useful message.
|
|
29
|
+
*/
|
|
30
|
+
import { Hono } from 'hono';
|
|
31
|
+
import type { StudioContext } from './api.ts';
|
|
32
|
+
export declare function createScrapbookMutationsRouter(ctx: StudioContext): Hono;
|
|
33
|
+
//# sourceMappingURL=scrapbook-mutations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scrapbook-mutations.d.ts","sourceRoot":"","sources":["../../src/routes/scrapbook-mutations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAc5B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AA0F9C,wBAAgB,8BAA8B,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAqSvE"}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation endpoints for the standalone scrapbook viewer
|
|
3
|
+
* (`/dev/scrapbook/<site>/<path>`). Five POST endpoints — save, rename,
|
|
4
|
+
* delete, create, upload — that the client at
|
|
5
|
+
* `plugins/deskwork-studio/public/src/scrapbook-client.ts` calls when
|
|
6
|
+
* the operator edits a slip, renames it, deletes it, drafts a new note,
|
|
7
|
+
* or drops a file onto the page.
|
|
8
|
+
*
|
|
9
|
+
* Phase 13 ported the client chrome but never landed these routes;
|
|
10
|
+
* Phase 16d retrofitted only the read path. v0.4.1 closes the gap
|
|
11
|
+
* (issue #21).
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - Path resolution + traversal protection runs through
|
|
15
|
+
* `@deskwork/core/scrapbook` helpers (`scrapbookFilePath` etc.).
|
|
16
|
+
* Those throw on `..` sequences, absolute paths, and any filename
|
|
17
|
+
* that escapes the scrapbook dir; we surface those as 400.
|
|
18
|
+
* - The client speaks `{ site, slug, filename, body }` for save/create,
|
|
19
|
+
* `{ site, slug, oldName, newName }` for rename, `{ site, slug,
|
|
20
|
+
* filename }` for delete, multipart `{ site, slug, file }` for
|
|
21
|
+
* upload. Endpoints accept those exact field names.
|
|
22
|
+
* - We never log file contents on save / upload (privacy). Logging
|
|
23
|
+
* filename + slug is fine but kept silent here — the studio's
|
|
24
|
+
* console is the operator's, not a server log.
|
|
25
|
+
* - Errors from the core helpers carry text like `"file not found:
|
|
26
|
+
* <name>"`, `"file already exists: <name>"`, or `"resolved path
|
|
27
|
+
* escapes scrapbook dir"`. We map those onto the right HTTP code
|
|
28
|
+
* so the client UI can flash a useful message.
|
|
29
|
+
*/
|
|
30
|
+
import { Hono } from 'hono';
|
|
31
|
+
import { classify, createScrapbookMarkdown, deleteScrapbookFile, renameScrapbookFile, saveScrapbookFile, writeScrapbookUpload, } from '@deskwork/core/scrapbook';
|
|
32
|
+
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
33
|
+
import { dirname } from 'node:path';
|
|
34
|
+
import { scrapbookFilePath } from '@deskwork/core/scrapbook';
|
|
35
|
+
/**
|
|
36
|
+
* Validate the common `{ site, slug, secret? }` envelope. Returns a
|
|
37
|
+
* typed object or a 400/404 Response that the caller propagates
|
|
38
|
+
* directly. The site existence check is here so every mutation 404s
|
|
39
|
+
* on unknown sites the same way the read endpoint does.
|
|
40
|
+
*/
|
|
41
|
+
function checkEnvelope(ctx, body) {
|
|
42
|
+
const site = body.site;
|
|
43
|
+
const slug = body.slug;
|
|
44
|
+
if (typeof site !== 'string' || site.length === 0) {
|
|
45
|
+
return { error: 'site is required', status: 400 };
|
|
46
|
+
}
|
|
47
|
+
if (typeof slug !== 'string' || slug.length === 0) {
|
|
48
|
+
return { error: 'slug is required', status: 400 };
|
|
49
|
+
}
|
|
50
|
+
if (!(site in ctx.config.sites)) {
|
|
51
|
+
return { error: `unknown site: ${site}`, status: 404 };
|
|
52
|
+
}
|
|
53
|
+
const secretRaw = body.secret;
|
|
54
|
+
if (secretRaw !== undefined && typeof secretRaw !== 'boolean') {
|
|
55
|
+
return { error: 'secret must be a boolean when provided', status: 400 };
|
|
56
|
+
}
|
|
57
|
+
return { site, slug, secret: secretRaw === true };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Map a core-helper error message onto the right HTTP status. The core
|
|
61
|
+
* helpers throw plain `Error` with descriptive text; we keep the text
|
|
62
|
+
* in the response body so the client can surface it.
|
|
63
|
+
*/
|
|
64
|
+
function statusForError(message) {
|
|
65
|
+
if (/already exists/i.test(message))
|
|
66
|
+
return 409;
|
|
67
|
+
if (/not found/i.test(message))
|
|
68
|
+
return 404;
|
|
69
|
+
// Slug, filename, traversal, and missing-arg errors are all 400.
|
|
70
|
+
return 400;
|
|
71
|
+
}
|
|
72
|
+
function isJsonObject(v) {
|
|
73
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
74
|
+
}
|
|
75
|
+
async function readJson(c) {
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = await c.req.json();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false, status: 400, error: 'invalid JSON body' };
|
|
82
|
+
}
|
|
83
|
+
if (!isJsonObject(raw)) {
|
|
84
|
+
return { ok: false, status: 400, error: 'body must be a JSON object' };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, value: raw };
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Route module
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
export function createScrapbookMutationsRouter(ctx) {
|
|
92
|
+
const app = new Hono();
|
|
93
|
+
// POST /api/dev/scrapbook/save
|
|
94
|
+
// Body: { site, slug, filename, body }
|
|
95
|
+
// Behavior: write `body` to <contentDir>/<slug>/scrapbook/<filename>.
|
|
96
|
+
// Creates the file if missing (delegates to create when absent so the
|
|
97
|
+
// operator's first save lands rather than 404'ing).
|
|
98
|
+
app.post('/save', async (c) => {
|
|
99
|
+
const parsed = await readJson(c);
|
|
100
|
+
if (!parsed.ok)
|
|
101
|
+
return c.json({ error: parsed.error }, parsed.status);
|
|
102
|
+
const env = checkEnvelope(ctx, parsed.value);
|
|
103
|
+
if ('error' in env)
|
|
104
|
+
return c.json({ error: env.error }, env.status);
|
|
105
|
+
const filename = parsed.value.filename;
|
|
106
|
+
const bodyText = parsed.value.body;
|
|
107
|
+
if (typeof filename !== 'string' || filename.length === 0) {
|
|
108
|
+
return c.json({ error: 'filename is required' }, 400);
|
|
109
|
+
}
|
|
110
|
+
if (typeof bodyText !== 'string') {
|
|
111
|
+
return c.json({ error: 'body must be a string' }, 400);
|
|
112
|
+
}
|
|
113
|
+
let item;
|
|
114
|
+
try {
|
|
115
|
+
// Resolve to test for existence. If absent, create-then-return.
|
|
116
|
+
const abs = scrapbookFilePath(ctx.projectRoot, ctx.config, env.site, env.slug, filename, { secret: env.secret });
|
|
117
|
+
if (!existsSync(abs)) {
|
|
118
|
+
// Only `.md` files can be created by the create helper, but
|
|
119
|
+
// save's contract is "write whatever was sent". For non-md
|
|
120
|
+
// files that don't yet exist, fall through to upload semantics.
|
|
121
|
+
if (filename.endsWith('.md')) {
|
|
122
|
+
item = createScrapbookMarkdown(ctx.projectRoot, ctx.config, env.site, env.slug, filename, bodyText, { secret: env.secret });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
item = writeScrapbookUpload(ctx.projectRoot, ctx.config, env.site, env.slug, filename, Buffer.from(bodyText, 'utf-8'), { secret: env.secret });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
item = saveScrapbookFile(ctx.projectRoot, ctx.config, env.site, env.slug, filename, bodyText, { secret: env.secret });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
134
|
+
return c.json({ error: reason }, statusForError(reason));
|
|
135
|
+
}
|
|
136
|
+
return c.json({ item }, 200);
|
|
137
|
+
});
|
|
138
|
+
// POST /api/dev/scrapbook/rename
|
|
139
|
+
// Body: { site, slug, oldName, newName, secret?, toSecret? }
|
|
140
|
+
//
|
|
141
|
+
// Two modes:
|
|
142
|
+
// - In-place rename: secret = source location (defaults to false);
|
|
143
|
+
// toSecret omitted (or equal to secret). The file is renamed
|
|
144
|
+
// inside the same section.
|
|
145
|
+
// - Cross-section move (#28): secret = source, toSecret = target
|
|
146
|
+
// section. When secret !== toSecret, the file is moved between
|
|
147
|
+
// `scrapbook/` and `scrapbook/secret/`. The studio's UI exposes
|
|
148
|
+
// this as "Mark secret" / "Mark public".
|
|
149
|
+
//
|
|
150
|
+
// 409 if the target newName already exists (in the destination
|
|
151
|
+
// section). 404 if oldName is missing in the source section.
|
|
152
|
+
app.post('/rename', async (c) => {
|
|
153
|
+
const parsed = await readJson(c);
|
|
154
|
+
if (!parsed.ok)
|
|
155
|
+
return c.json({ error: parsed.error }, parsed.status);
|
|
156
|
+
const env = checkEnvelope(ctx, parsed.value);
|
|
157
|
+
if ('error' in env)
|
|
158
|
+
return c.json({ error: env.error }, env.status);
|
|
159
|
+
const oldName = parsed.value.oldName;
|
|
160
|
+
const newName = parsed.value.newName;
|
|
161
|
+
if (typeof oldName !== 'string' || oldName.length === 0) {
|
|
162
|
+
return c.json({ error: 'oldName is required' }, 400);
|
|
163
|
+
}
|
|
164
|
+
if (typeof newName !== 'string' || newName.length === 0) {
|
|
165
|
+
return c.json({ error: 'newName is required' }, 400);
|
|
166
|
+
}
|
|
167
|
+
const toSecretRaw = parsed.value.toSecret;
|
|
168
|
+
if (toSecretRaw !== undefined && typeof toSecretRaw !== 'boolean') {
|
|
169
|
+
return c.json({ error: 'toSecret must be a boolean when provided' }, 400);
|
|
170
|
+
}
|
|
171
|
+
const toSecret = toSecretRaw === undefined ? env.secret : toSecretRaw;
|
|
172
|
+
let item;
|
|
173
|
+
try {
|
|
174
|
+
if (toSecret === env.secret) {
|
|
175
|
+
item = renameScrapbookFile(ctx.projectRoot, ctx.config, env.site, env.slug, oldName, newName, { secret: env.secret });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Cross-section move. Use the path-resolver to compute the
|
|
179
|
+
// source and destination absolute paths under the right
|
|
180
|
+
// sub-roots (and let the resolver enforce traversal guards).
|
|
181
|
+
// Then physically rename across the two paths.
|
|
182
|
+
const srcAbs = scrapbookFilePath(ctx.projectRoot, ctx.config, env.site, env.slug, oldName, { secret: env.secret });
|
|
183
|
+
const dstAbs = scrapbookFilePath(ctx.projectRoot, ctx.config, env.site, env.slug, newName, { secret: toSecret });
|
|
184
|
+
if (!existsSync(srcAbs)) {
|
|
185
|
+
return c.json({ error: `file not found: "${oldName}"` }, 404);
|
|
186
|
+
}
|
|
187
|
+
if (existsSync(dstAbs)) {
|
|
188
|
+
return c.json({ error: `target name already exists: "${newName}"` }, 409);
|
|
189
|
+
}
|
|
190
|
+
// Ensure the destination directory exists (creates `secret/`
|
|
191
|
+
// when promoting public → secret for the first time).
|
|
192
|
+
mkdirSync(dirname(dstAbs), { recursive: true });
|
|
193
|
+
renameSync(srcAbs, dstAbs);
|
|
194
|
+
const st = statSync(dstAbs);
|
|
195
|
+
// Build the response item the same shape the helper uses.
|
|
196
|
+
item = {
|
|
197
|
+
name: newName,
|
|
198
|
+
kind: classify(newName),
|
|
199
|
+
size: st.size,
|
|
200
|
+
mtime: st.mtime.toISOString(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
206
|
+
return c.json({ error: reason }, statusForError(reason));
|
|
207
|
+
}
|
|
208
|
+
return c.json({ item }, 200);
|
|
209
|
+
});
|
|
210
|
+
// POST /api/dev/scrapbook/delete
|
|
211
|
+
// Body: { site, slug, filename }
|
|
212
|
+
// Behavior: unlink the file. 404 if missing.
|
|
213
|
+
app.post('/delete', async (c) => {
|
|
214
|
+
const parsed = await readJson(c);
|
|
215
|
+
if (!parsed.ok)
|
|
216
|
+
return c.json({ error: parsed.error }, parsed.status);
|
|
217
|
+
const env = checkEnvelope(ctx, parsed.value);
|
|
218
|
+
if ('error' in env)
|
|
219
|
+
return c.json({ error: env.error }, env.status);
|
|
220
|
+
const filename = parsed.value.filename;
|
|
221
|
+
if (typeof filename !== 'string' || filename.length === 0) {
|
|
222
|
+
return c.json({ error: 'filename is required' }, 400);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
deleteScrapbookFile(ctx.projectRoot, ctx.config, env.site, env.slug, filename, { secret: env.secret });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
229
|
+
return c.json({ error: reason }, statusForError(reason));
|
|
230
|
+
}
|
|
231
|
+
return c.json({ ok: true }, 200);
|
|
232
|
+
});
|
|
233
|
+
// POST /api/dev/scrapbook/create
|
|
234
|
+
// Body: { site, slug, filename, body? }
|
|
235
|
+
// Behavior: create a new markdown file. 409 if it already exists.
|
|
236
|
+
app.post('/create', async (c) => {
|
|
237
|
+
const parsed = await readJson(c);
|
|
238
|
+
if (!parsed.ok)
|
|
239
|
+
return c.json({ error: parsed.error }, parsed.status);
|
|
240
|
+
const env = checkEnvelope(ctx, parsed.value);
|
|
241
|
+
if ('error' in env)
|
|
242
|
+
return c.json({ error: env.error }, env.status);
|
|
243
|
+
const filename = parsed.value.filename;
|
|
244
|
+
const bodyText = parsed.value.body ?? '';
|
|
245
|
+
if (typeof filename !== 'string' || filename.length === 0) {
|
|
246
|
+
return c.json({ error: 'filename is required' }, 400);
|
|
247
|
+
}
|
|
248
|
+
if (typeof bodyText !== 'string') {
|
|
249
|
+
return c.json({ error: 'body must be a string' }, 400);
|
|
250
|
+
}
|
|
251
|
+
let item;
|
|
252
|
+
try {
|
|
253
|
+
item = createScrapbookMarkdown(ctx.projectRoot, ctx.config, env.site, env.slug, filename, bodyText, { secret: env.secret });
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
257
|
+
return c.json({ error: reason }, statusForError(reason));
|
|
258
|
+
}
|
|
259
|
+
return c.json({ item }, 200);
|
|
260
|
+
});
|
|
261
|
+
// POST /api/dev/scrapbook/upload
|
|
262
|
+
// Multipart body: { site, slug, file, secret? }
|
|
263
|
+
// Behavior: save the uploaded file (binary-safe) at
|
|
264
|
+
// <contentDir>/<slug>/scrapbook/<file.name>, or under
|
|
265
|
+
// `scrapbook/secret/` when the `secret` form field is the literal
|
|
266
|
+
// string "true". 409 if it already exists — operator must rename
|
|
267
|
+
// or delete first.
|
|
268
|
+
app.post('/upload', async (c) => {
|
|
269
|
+
let form;
|
|
270
|
+
try {
|
|
271
|
+
form = await c.req.formData();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return c.json({ error: 'invalid multipart body' }, 400);
|
|
275
|
+
}
|
|
276
|
+
const site = form.get('site');
|
|
277
|
+
const slug = form.get('slug');
|
|
278
|
+
const file = form.get('file');
|
|
279
|
+
const secretField = form.get('secret');
|
|
280
|
+
const secret = typeof secretField === 'string' && secretField === 'true';
|
|
281
|
+
if (typeof site !== 'string' || site.length === 0) {
|
|
282
|
+
return c.json({ error: 'site is required' }, 400);
|
|
283
|
+
}
|
|
284
|
+
if (typeof slug !== 'string' || slug.length === 0) {
|
|
285
|
+
return c.json({ error: 'slug is required' }, 400);
|
|
286
|
+
}
|
|
287
|
+
if (!(site in ctx.config.sites)) {
|
|
288
|
+
return c.json({ error: `unknown site: ${site}` }, 404);
|
|
289
|
+
}
|
|
290
|
+
if (!(file instanceof File)) {
|
|
291
|
+
return c.json({ error: 'file is required (multipart)' }, 400);
|
|
292
|
+
}
|
|
293
|
+
const filename = file.name;
|
|
294
|
+
if (typeof filename !== 'string' || filename.length === 0) {
|
|
295
|
+
return c.json({ error: 'uploaded file has no name' }, 400);
|
|
296
|
+
}
|
|
297
|
+
let item;
|
|
298
|
+
try {
|
|
299
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
300
|
+
item = writeScrapbookUpload(ctx.projectRoot, ctx.config, site, slug, filename, buf, { secret });
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
304
|
+
return c.json({ error: reason }, statusForError(reason));
|
|
305
|
+
}
|
|
306
|
+
return c.json({ item }, 200);
|
|
307
|
+
});
|
|
308
|
+
return app;
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=scrapbook-mutations.js.map
|