living-documentation 7.1.0 → 7.3.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/README.md CHANGED
@@ -31,6 +31,7 @@ ExtraFiles (added in the admin section) are always first, always expanded in a `
31
31
 
32
32
  - **Inline editing** — edit any document directly in the browser, saves to disk instantly
33
33
  - **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
34
+ - **File attachments** — drag & drop, paste or pick any non-image file (PDF, archives, office docs…) in the editor; uploaded under `DOCS_FOLDER/files/` and inserted as a paperclip link. Blocked extensions and size limits are configurable from the Admin panel.
34
35
  - **Snippet inserter** — click **🧩 Snippets** while editing to insert pre-built Markdown constructs at the cursor position:
35
36
  - *Simple snippets*: collapsible block (`<details>`), link, link to another document, anchor link, anchor link in another document, numbered list (3 levels), bullet list (3 levels), code block, blockquote, horizontal separator, image
36
37
  - *Complex snippets*: **table editor** (dynamic rows/columns grid, generates aligned Markdown table) and **tree editor** (indentation-based ASCII tree, generates a `text` code block with `├──` / `└──` connectors)
@@ -211,22 +212,42 @@ Use the Admin panel's **General — Extra Files** section to browse the filesyst
211
212
  ```
212
213
  living-documentation/
213
214
  ├── bin/
214
- │ └── cli.ts CLI entry point
215
+ │ └── cli.ts CLI entry point (Commander)
215
216
  ├── src/
216
- │ ├── server.ts Express app
217
+ │ ├── server.ts Express app (mounts routes + static frontend)
217
218
  │ ├── routes/
218
- │ │ ├── documents.ts Documents API (list, search, read, write)
219
- │ │ ├── config.ts Config API
220
- │ │ ├── browse.ts Filesystem browser API
221
- │ │ └── images.ts Image upload API
219
+ │ │ ├── documents.ts Documents API (list, search, read, write, create, delete)
220
+ │ │ ├── config.ts Config API
221
+ │ │ ├── browse.ts Filesystem browser API (+ mkdir)
222
+ │ │ ├── images.ts Image upload API
223
+ │ │ ├── files.ts File attachment upload API (paperclip)
224
+ │ │ ├── wordcloud.ts Word cloud raw text reader
225
+ │ │ ├── diagrams.ts Diagrams CRUD API (vis-network JSON)
226
+ │ │ ├── annotations.ts Per-document highlight markers API
227
+ │ │ └── export.ts HTML export (PDF, Notion, Confluence zip)
228
+ │ ├── mcp/
229
+ │ │ ├── server.ts Model Context Protocol server (Streamable HTTP)
230
+ │ │ └── tools/
231
+ │ │ ├── documents.ts MCP tools: list/read/create document
232
+ │ │ ├── diagrams.ts MCP tools: list/read/create diagram
233
+ │ │ └── source.ts MCP tools: list/read/search source files
222
234
  │ ├── lib/
223
- │ │ ├── parser.ts Filename parser
224
- │ │ └── config.ts Config management
235
+ │ │ ├── parser.ts Filename parser
236
+ │ │ └── config.ts Config management (.living-doc.json)
225
237
  │ └── frontend/
226
- │ ├── index.html Main viewer
227
- └── admin.html Admin panel
238
+ │ ├── index.html Main viewer shell
239
+ ├── admin.html Admin panel
240
+ │ ├── diagram.html Diagram editor shell
241
+ │ ├── i18n.js i18n loader (window.t + data-i18n binding)
242
+ │ ├── i18n/{en,fr}.json Translation catalogs
243
+ │ ├── wordcloud.js Word cloud logic
244
+ │ ├── vendor/ Vendored browser libraries (wordcloud2.js)
245
+ │ ├── *.js Viewer modules (state, sidebar, search, documents, …)
246
+ │ └── diagram/*.js Diagram editor modules (network, panels, history, …)
228
247
  ├── scripts/
229
- │ └── copy-assets.js Build helper (copies HTML to dist/)
248
+ │ └── copy-assets.ts Build helper (copies frontend + starting-doc to dist/)
249
+ ├── starting-doc/ Sample docs shipped with the npm package
250
+ ├── documentation/adrs/ Architecture Decision Records for this project
230
251
  ├── package.json
231
252
  └── tsconfig.json
232
253
  ```
@@ -235,16 +256,34 @@ living-documentation/
235
256
 
236
257
  ## API reference
237
258
 
238
- | Method | Endpoint | Description |
239
- | ------ | -------------------------- | ------------------------------------------------------------------ |
240
- | `GET` | `/api/documents` | List all documents with metadata (includes extra files) |
241
- | `GET` | `/api/documents/:id` | Get document content + rendered HTML |
242
- | `PUT` | `/api/documents/:id` | Save document content to disk |
243
- | `GET` | `/api/documents/search?q=` | Full-text search |
244
- | `GET` | `/api/config` | Read config |
245
- | `PUT` | `/api/config` | Update config (`title`, `theme`, `filenamePattern`, `extraFiles`) |
246
- | `GET` | `/api/browse?path=` | List directories and `.md` files at a given filesystem path |
247
- | `POST` | `/api/images/upload` | Upload a base64 image; saved to `DOCS_FOLDER/images/` |
259
+ | Method | Endpoint | Description |
260
+ | -------- | ------------------------------ | ------------------------------------------------------------------ |
261
+ | `GET` | `/api/documents` | List all documents with metadata (includes extra files) |
262
+ | `GET` | `/api/documents/:id` | Get document content + rendered HTML |
263
+ | `POST` | `/api/documents` | Create a new document from `{ title, category, folder?, content? }` |
264
+ | `PUT` | `/api/documents/:id` | Save document content to disk |
265
+ | `DELETE` | `/api/documents/:id` | Delete a document |
266
+ | `GET` | `/api/documents/search?q=` | Full-text search |
267
+ | `GET` | `/api/config` | Read config |
268
+ | `PUT` | `/api/config` | Update config (`title`, `theme`, `filenamePattern`, `extraFiles`, `showDiagramDebug`, `sourceRoot`, `blockedFileExtensions`) |
269
+ | `GET` | `/api/browse?path=` | List directories and `.md` files at a given filesystem path |
270
+ | `GET` | `/api/browse/alldirs?path=` | List directories recursively (for the folder picker) |
271
+ | `POST` | `/api/browse/mkdir` | Create a new folder under the docs root |
272
+ | `POST` | `/api/images/upload` | Upload a base64 image; saved to `DOCS_FOLDER/images/` |
273
+ | `POST` | `/api/files/upload` | Upload a base64 file attachment; saved to `DOCS_FOLDER/files/` |
274
+ | `GET` | `/api/diagrams` | List saved diagrams |
275
+ | `GET` | `/api/diagrams/:id` | Read a single diagram (nodes + edges) |
276
+ | `PUT` | `/api/diagrams/:id` | Create or update a diagram |
277
+ | `DELETE` | `/api/diagrams/:id` | Delete a diagram |
278
+ | `GET` | `/api/annotations` | List annotations for all documents |
279
+ | `GET` | `/api/annotations/:docId` | List annotations for one document |
280
+ | `POST` | `/api/annotations/:docId` | Add an annotation |
281
+ | `DELETE` | `/api/annotations/:docId/:id` | Delete one annotation |
282
+ | `POST` | `/api/export/html` | Export a document (or a zip bundle) as HTML — Notion / Confluence modes |
283
+ | `POST` | `/api/export/markdown` | Export documents as a Markdown bundle |
284
+ | `GET` | `/api/wordcloud?path=&ext=` | Recursively concatenate matching files as raw text |
285
+ | `POST` | `/mcp` | Model Context Protocol endpoint (Streamable HTTP) |
286
+ | `GET` | `/mcp` | Summary of available MCP tools and prompts |
248
287
 
249
288
  ---
250
289
 
@@ -268,11 +307,18 @@ A `GET http://localhost:4321/mcp` returns a JSON summary of available tools for
268
307
 
269
308
  | Tool | Description |
270
309
  |---|---|
310
+ | `get_server_guide` | Return the server guide (purpose, workflow, diagram conventions, coordinate system) |
271
311
  | `list_documents` | List all documents with their id, title, category and folder |
272
312
  | `read_document` | Read the raw Markdown content of a document by its id |
273
313
  | `create_document` | Create a new Markdown document (filename generated from the configured pattern) |
274
314
  | `list_diagrams` | List all saved diagrams with their id and title |
275
- | `create_diagram` | Create a diagram from nodes and edges (shapes, colors, labels) |
315
+ | `read_diagram` | Read the nodes and edges of a diagram (same shape as `create_diagram` input) |
316
+ | `create_diagram` | Create or overwrite a diagram from nodes and edges (shapes, colors, labels) |
317
+ | `list_source_files` | List project source files under `sourceRoot` (fallback only) |
318
+ | `read_source_file` | Read a source file under `sourceRoot` (fallback only) |
319
+ | `search_source` | Grep-like text search across files under `sourceRoot` |
320
+
321
+ Prompts (`generate-context-diagram`, `generate-container-diagram`, `generate-uml-diagram`, `update-diagram-from-docs`, `generate-screen-guide`, `flow`, `erd`) are exposed alongside the tools for clients that surface MCP prompts to the user.
276
322
 
277
323
  ### Installation — Claude Desktop
278
324
 
@@ -316,6 +316,35 @@
316
316
  </div>
317
317
  </div>
318
318
 
319
+ <!-- ── File Attachments (full width) ── -->
320
+ <div
321
+ class="mt-8 rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 space-y-3"
322
+ >
323
+ <div>
324
+ <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
325
+ <span data-i18n="admin.files.title">File Attachments</span>
326
+ </h3>
327
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400" data-i18n="admin.files.description">
328
+ Extensions that are rejected by the <code>/api/files/upload</code> endpoint. One extension per line (or comma/space separated), without the leading dot.
329
+ </p>
330
+ </div>
331
+ <label
332
+ class="block text-xs font-medium text-gray-500 dark:text-gray-400"
333
+ for="field-blocked-extensions"
334
+ data-i18n="admin.files.blocked_label"
335
+ >Blocked extensions</label>
336
+ <textarea
337
+ id="field-blocked-extensions"
338
+ name="blockedFileExtensions"
339
+ rows="3"
340
+ class="w-full px-3 py-2 font-mono text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
341
+ placeholder="exe sh bat cmd com scr ps1 msi"
342
+ ></textarea>
343
+ <p class="text-xs text-gray-400 dark:text-gray-500" data-i18n="admin.files.hint">
344
+ Defaults to common executable formats. Max upload size is 19 MB per file.
345
+ </p>
346
+ </div>
347
+
319
348
  <!-- ── Diagram Palettes (full width) ── -->
320
349
  <div
321
350
  class="mt-8 rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 divide-y divide-gray-100 dark:divide-gray-800"
@@ -497,6 +526,8 @@
497
526
  cfg.filenamePattern || "";
498
527
  document.getElementById("field-debug").checked =
499
528
  !!cfg.showDiagramDebug;
529
+ document.getElementById("field-blocked-extensions").value =
530
+ (cfg.blockedFileExtensions || []).join(" ");
500
531
  updatePreview(cfg.filenamePattern);
501
532
  initExtraFiles(cfg);
502
533
  initPalettes(cfg);
@@ -525,6 +556,12 @@
525
556
  const sourceRootRaw = document
526
557
  .getElementById("field-source-root")
527
558
  .value.trim();
559
+ const blockedExtensions = document
560
+ .getElementById("field-blocked-extensions")
561
+ .value
562
+ .split(/[\s,]+/)
563
+ .map((e) => e.trim().replace(/^\.+/, "").toLowerCase())
564
+ .filter((e) => /^[a-z0-9]+$/.test(e));
528
565
  const payload = {
529
566
  title: document.getElementById("field-title").value.trim(),
530
567
  theme: document.getElementById("field-theme").value,
@@ -534,6 +571,7 @@
534
571
  diagramNodePalette: [...nodePalette],
535
572
  diagramEdgePalette: [...edgePalette],
536
573
  sourceRoot: sourceRootRaw === "" ? null : sourceRootRaw,
574
+ blockedFileExtensions: blockedExtensions,
537
575
  };
538
576
 
539
577
  try {
@@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", async () => {
11
11
  setupDarkToggle();
12
12
  setupSearch();
13
13
  wcRestorePrefs();
14
+ if (typeof initFileAttach === "function") initFileAttach();
14
15
  await loadConfig();
15
16
  await loadDocuments();
16
17
 
@@ -86,5 +87,10 @@ document
86
87
  window.addEventListener("popstate", (e) => {
87
88
  const id =
88
89
  e.state?.docId || new URLSearchParams(location.search).get("doc");
89
- if (id) openDocument(id, true);
90
+ const anchor =
91
+ e.state?.anchor ||
92
+ (location.hash && location.hash.length > 1
93
+ ? location.hash.slice(1)
94
+ : null);
95
+ if (id) openDocument(id, true, false, anchor);
90
96
  });
@@ -43,17 +43,23 @@ async function refreshAnnotationCounts() {
43
43
  }
44
44
  }
45
45
 
46
- async function openDocument(id, skipHistory = false, fromLink = false) {
46
+ async function openDocument(id, skipHistory = false, fromLink = false, anchor = null) {
47
47
  // Track navigation history for breadcrumb trail
48
48
  // fromLink===true : forward navigation via in-doc link → push current to stack
49
+ // (unless target is already in the stack → rewind instead of loop)
49
50
  // fromLink==="restore" : back navigation via history breadcrumb → stack already trimmed, don't touch
50
51
  // fromLink===false : sidebar/direct navigation → reset stack
51
52
  if (fromLink === true && currentDocId && currentDocId !== id) {
52
- const prev = allDocs && allDocs.find((d) => d.id === currentDocId);
53
- navHistory.push({
54
- id: currentDocId,
55
- title: prev ? prev.title : currentDocId,
56
- });
53
+ const existingIdx = navHistory.findIndex((e) => e.id === id);
54
+ if (existingIdx !== -1) {
55
+ navHistory = navHistory.slice(0, existingIdx);
56
+ } else {
57
+ const prev = allDocs && allDocs.find((d) => d.id === currentDocId);
58
+ navHistory.push({
59
+ id: currentDocId,
60
+ title: prev ? prev.title : currentDocId,
61
+ });
62
+ }
57
63
  } else if (!fromLink) {
58
64
  navHistory = [];
59
65
  }
@@ -105,7 +111,8 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
105
111
  if (!skipHistory) {
106
112
  const url = new URL(location.href);
107
113
  url.searchParams.set("doc", id);
108
- history.pushState({ docId: id }, "", url);
114
+ url.hash = anchor ? `#${anchor}` : "";
115
+ history.pushState({ docId: id, anchor: anchor || null }, "", url);
109
116
  }
110
117
 
111
118
  document.getElementById("welcome").classList.add("hidden");
@@ -158,14 +165,16 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
158
165
  hljs.highlightElement(block);
159
166
  });
160
167
 
161
- // Intercept inter-doc links (?doc=X) to stay in SPA and track origin
168
+ // Intercept inter-doc links (?doc=X[#anchor]) to stay in SPA and track origin
162
169
  contentEl.querySelectorAll("a[href]").forEach((a) => {
163
170
  const href = a.getAttribute("href");
164
171
  const m = href && href.match(/[?&]doc=([^&#]+)/);
165
172
  if (!m) return;
173
+ const hashIdx = href.indexOf("#");
174
+ const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
166
175
  a.addEventListener("click", (e) => {
167
176
  e.preventDefault();
168
- openDocument(decodeURIComponent(m[1]), false, true);
177
+ openDocument(decodeURIComponent(m[1]), false, true, anchor);
169
178
  });
170
179
  });
171
180
 
@@ -199,10 +208,11 @@ async function openDocument(id, skipHistory = false, fromLink = false) {
199
208
 
200
209
  document.title = doc.title;
201
210
 
202
- // Scroll to anchor if present in URL
203
- const hash = window.location.hash;
204
- if (hash && hash.length > 1) {
205
- scrollToAnchor(hash.slice(1));
211
+ // Scroll to anchor if present (explicit param wins over URL hash)
212
+ const targetAnchor =
213
+ anchor || (window.location.hash ? window.location.hash.slice(1) : "");
214
+ if (targetAnchor) {
215
+ scrollToAnchor(targetAnchor);
206
216
  } else {
207
217
  document.getElementById("content-area").scrollTop = 0;
208
218
  }
@@ -331,9 +341,11 @@ async function saveDocument() {
331
341
  const href = a.getAttribute("href");
332
342
  const m = href && href.match(/[?&]doc=([^&#]+)/);
333
343
  if (!m) return;
344
+ const hashIdx = href.indexOf("#");
345
+ const anchor = hashIdx !== -1 ? href.slice(hashIdx + 1) : null;
334
346
  a.addEventListener("click", (e) => {
335
347
  e.preventDefault();
336
- openDocument(decodeURIComponent(m[1]), false, true);
348
+ openDocument(decodeURIComponent(m[1]), false, true, anchor);
337
349
  });
338
350
  });
339
351
 
@@ -0,0 +1,178 @@
1
+ // ── File attachments ────────────────────────────────────────────────────────
2
+ // Attach arbitrary files (pdf, docx, zip, …) to a document via drag & drop,
3
+ // paste or the Snippets panel. Files are saved under DOCS_FOLDER/files/ and
4
+ // inserted as `[📎 name.ext](./files/<server-name>.<ext>)` in the markdown.
5
+ //
6
+ // Client-side size guard matches server-side MAX_FILE_BYTES (19 MB) so the
7
+ // user gets an immediate toast when Express would otherwise reject the body.
8
+
9
+ const FILE_MAX_BYTES = 19 * 1024 * 1024;
10
+
11
+ function _fileAttachNotify(message, isError) {
12
+ const msgEl = document.getElementById("edit-save-msg");
13
+ if (msgEl) {
14
+ msgEl.textContent = message;
15
+ msgEl.className = isError
16
+ ? "text-xs text-red-500 dark:text-red-400"
17
+ : "text-xs text-gray-400";
18
+ }
19
+ }
20
+
21
+ function _fileAttachReadAsBase64(file) {
22
+ return new Promise((resolve, reject) => {
23
+ const reader = new FileReader();
24
+ reader.onload = () => resolve(reader.result);
25
+ reader.onerror = reject;
26
+ reader.readAsDataURL(file);
27
+ });
28
+ }
29
+
30
+ function _fileAttachInsertAtCursor(markdown, start, end) {
31
+ const editor = document.getElementById("doc-editor");
32
+ if (!editor) return;
33
+ const s = typeof start === "number" ? start : editor.selectionStart;
34
+ const e = typeof end === "number" ? end : editor.selectionEnd;
35
+ const before = editor.value.slice(0, s);
36
+ const after = editor.value.slice(e);
37
+ editor.value = before + markdown + after;
38
+ editor.selectionStart = editor.selectionEnd = s + markdown.length;
39
+ editor.focus();
40
+ }
41
+
42
+ async function uploadAttachedFile(file, cursorStart, cursorEnd) {
43
+ if (!file) return;
44
+
45
+ if (file.size > FILE_MAX_BYTES) {
46
+ const mb = (file.size / 1024 / 1024).toFixed(1);
47
+ _fileAttachNotify(
48
+ (window.t ? window.t("doc.file_too_large") : "File too large") +
49
+ ` (${mb} MB, max 19 MB)`,
50
+ true,
51
+ );
52
+ return;
53
+ }
54
+
55
+ _fileAttachNotify(
56
+ window.t ? window.t("doc.uploading_file") : "Uploading file…",
57
+ false,
58
+ );
59
+
60
+ try {
61
+ const base64 = await _fileAttachReadAsBase64(file);
62
+ const res = await fetch("/api/files/upload", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ data: base64, name: file.name }),
66
+ });
67
+ if (!res.ok) {
68
+ let msg = "";
69
+ try {
70
+ const body = await res.json();
71
+ msg = body.error || "";
72
+ } catch {
73
+ msg = await res.text();
74
+ }
75
+ throw new Error(msg || `HTTP ${res.status}`);
76
+ }
77
+ const { url, originalName } = await res.json();
78
+ const label = originalName || file.name;
79
+ const markdown = `[📎 ${label}](${url})`;
80
+ _fileAttachInsertAtCursor(markdown, cursorStart, cursorEnd);
81
+ _fileAttachNotify("", false);
82
+ } catch (err) {
83
+ _fileAttachNotify(
84
+ (window.t ? window.t("doc.file_upload_failed") : "File upload failed: ") +
85
+ (err.message || String(err)),
86
+ true,
87
+ );
88
+ }
89
+ }
90
+
91
+ function _fileAttachHandleDrop(e) {
92
+ const editor = document.getElementById("doc-editor");
93
+ if (!editor || editor.classList.contains("hidden")) return;
94
+ const files = Array.from(e.dataTransfer?.files ?? []);
95
+ if (files.length === 0) return;
96
+
97
+ // Images keep flowing through image-paste.js → ignore them here.
98
+ const nonImage = files.filter((f) => !f.type.startsWith("image/"));
99
+ if (nonImage.length === 0) return;
100
+
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+
104
+ // Drop uses the current caret, not the drop coordinates — keeps things simple.
105
+ const start = editor.selectionStart;
106
+ const end = editor.selectionEnd;
107
+ (async () => {
108
+ let offset = 0;
109
+ for (const file of nonImage) {
110
+ await uploadAttachedFile(file, start + offset, end + offset);
111
+ // After insertion the caret is positioned AFTER the inserted markdown;
112
+ // re-read it for the next file so multiple drops append correctly.
113
+ offset = 0;
114
+ }
115
+ })();
116
+ }
117
+
118
+ function _fileAttachHandlePaste(e) {
119
+ const editor = document.getElementById("doc-editor");
120
+ if (!editor) return;
121
+ const files = Array.from(e.clipboardData?.files ?? []);
122
+ if (files.length === 0) return;
123
+ const nonImage = files.filter((f) => !f.type.startsWith("image/"));
124
+ if (nonImage.length === 0) return;
125
+
126
+ e.preventDefault();
127
+ const start = editor.selectionStart;
128
+ const end = editor.selectionEnd;
129
+ (async () => {
130
+ for (const file of nonImage) {
131
+ await uploadAttachedFile(file, start, end);
132
+ }
133
+ })();
134
+ }
135
+
136
+ function _fileAttachHandleDragOver(e) {
137
+ const editor = document.getElementById("doc-editor");
138
+ if (!editor || editor.classList.contains("hidden")) return;
139
+ if (e.dataTransfer?.types?.includes("Files")) {
140
+ e.preventDefault();
141
+ e.dataTransfer.dropEffect = "copy";
142
+ }
143
+ }
144
+
145
+ function initFileAttach() {
146
+ const editor = document.getElementById("doc-editor");
147
+ if (!editor) return;
148
+ editor.addEventListener("drop", _fileAttachHandleDrop);
149
+ editor.addEventListener("dragover", _fileAttachHandleDragOver);
150
+ editor.addEventListener("paste", _fileAttachHandlePaste);
151
+ }
152
+
153
+ // Triggered by the 📎 snippet button — opens the native file picker.
154
+ function openFilePicker() {
155
+ const editor = document.getElementById("doc-editor");
156
+ if (!editor) return;
157
+ const start = editor.selectionStart;
158
+ const end = editor.selectionEnd;
159
+
160
+ let input = document.getElementById("file-attach-picker");
161
+ if (!input) {
162
+ input = document.createElement("input");
163
+ input.type = "file";
164
+ input.id = "file-attach-picker";
165
+ input.style.display = "none";
166
+ document.body.appendChild(input);
167
+ }
168
+ input.value = "";
169
+ input.onchange = async () => {
170
+ const file = input.files && input.files[0];
171
+ if (file) await uploadAttachedFile(file, start, end);
172
+ };
173
+ input.click();
174
+ }
175
+
176
+ window.initFileAttach = initFileAttach;
177
+ window.openFilePicker = openFilePicker;
178
+ window.uploadAttachedFile = uploadAttachedFile;
@@ -58,6 +58,9 @@
58
58
  "doc.save": "Save document",
59
59
  "doc.uploading_image": "Uploading image…",
60
60
  "doc.image_upload_failed": "Image upload failed: ",
61
+ "doc.uploading_file": "Uploading file…",
62
+ "doc.file_upload_failed": "File upload failed: ",
63
+ "doc.file_too_large": "File too large",
61
64
  "doc.saving": "Saving…",
62
65
  "doc.failed_to_load": "Failed to load document: ",
63
66
  "doc.copied": "✓ Copied!",
@@ -149,6 +152,8 @@
149
152
  "snippet.link_anchor_label": "Anchor",
150
153
  "snippet.link_anchor_placeholder": "my-heading",
151
154
  "snippet.link_anchor_hint": "(without #, e.g. my-heading)",
155
+ "snippet.link_anchor_select_hint": "(pick a heading from the document)",
156
+ "snippet.link_anchor_no_headings": "No headings detected in this document",
152
157
  "snippet.link_target_doc_label": "Target document",
153
158
  "snippet.code_lang_label": "Language",
154
159
  "snippet.code_lang_hint": "(e.g. javascript, python, bash…)",
@@ -176,6 +181,9 @@
176
181
  "snippet.saving_btn": "Saving…",
177
182
  "snippet.detected_msg": "✓ Detected type: {type}. Fields pre-filled — edit then click Insert to replace selection.",
178
183
  "snippet.unknown_type_msg": "⚠ Selected text doesn't match any known snippet type. Choose a type below — the snippet will still replace the selection.",
184
+ "snippet.attachment": "📎 File attachment",
185
+ "snippet.attachment_help": "Click <strong>Insert</strong> to choose a file. It will be uploaded under the <code>files/</code> folder and inserted as a <i class=\"fa-solid fa-paperclip\"></i> link in your document.",
186
+ "snippet.attachment_alt": "Tip: you can also drag &amp; drop a file onto the editor, or paste it from the clipboard.",
179
187
 
180
188
  "modal.img_paste.title": "Paste clipboard image?",
181
189
  "modal.img_paste.filename_label": "Filename",
@@ -275,6 +283,11 @@
275
283
  "admin.extra_files.added_label": "Added",
276
284
  "admin.extra_files.loading": "Loading…",
277
285
 
286
+ "admin.files.title": "File Attachments",
287
+ "admin.files.description": "Extensions that are rejected by the <code>/api/files/upload</code> endpoint. One extension per line (or comma/space separated), without the leading dot.",
288
+ "admin.files.blocked_label": "Blocked extensions",
289
+ "admin.files.hint": "Defaults to common executable formats. Max upload size is 19 MB per file.",
290
+
278
291
  "admin.palette.title": "Diagram Color Palettes",
279
292
  "admin.palette.description": "Customize the colors available in the diagram editor. Changes take effect after saving.",
280
293
  "admin.palette.shapes_title": "Shape colors",
@@ -58,6 +58,9 @@
58
58
  "doc.save": "Enregistrer le document",
59
59
  "doc.uploading_image": "Téléversement de l'image…",
60
60
  "doc.image_upload_failed": "Échec du téléversement : ",
61
+ "doc.uploading_file": "Téléversement du fichier…",
62
+ "doc.file_upload_failed": "Échec du téléversement du fichier : ",
63
+ "doc.file_too_large": "Fichier trop volumineux",
61
64
  "doc.saving": "Enregistrement…",
62
65
  "doc.failed_to_load": "Impossible de charger le document : ",
63
66
  "doc.copied": "✓ Copié !",
@@ -149,6 +152,8 @@
149
152
  "snippet.link_anchor_label": "Ancre",
150
153
  "snippet.link_anchor_placeholder": "mon-titre",
151
154
  "snippet.link_anchor_hint": "(sans #, ex : mon-titre)",
155
+ "snippet.link_anchor_select_hint": "(choisissez un titre du document)",
156
+ "snippet.link_anchor_no_headings": "Aucun titre détecté dans ce document",
152
157
  "snippet.link_target_doc_label": "Document cible",
153
158
  "snippet.code_lang_label": "Langage",
154
159
  "snippet.code_lang_hint": "(ex : javascript, python, bash…)",
@@ -176,6 +181,9 @@
176
181
  "snippet.saving_btn": "Enregistrement…",
177
182
  "snippet.detected_msg": "✓ Type détecté : {type}. Les champs ont été pré-remplis — modifiez puis cliquez sur Insérer pour remplacer la sélection.",
178
183
  "snippet.unknown_type_msg": "⚠ Le texte sélectionné ne correspond à aucun type de snippet reconnu. Choisissez un type ci-dessous — le snippet remplacera quand même la sélection.",
184
+ "snippet.attachment": "📎 Pièce jointe",
185
+ "snippet.attachment_help": "Cliquez sur <strong>Insérer</strong> pour choisir un fichier. Il sera téléversé dans le dossier <code>files/</code> et inséré sous forme de lien <i class=\"fa-solid fa-paperclip\"></i> dans votre document.",
186
+ "snippet.attachment_alt": "Astuce : vous pouvez aussi glisser-déposer un fichier sur l'éditeur ou le coller depuis le presse-papiers.",
179
187
 
180
188
  "modal.img_paste.title": "Coller l'image du presse-papiers ?",
181
189
  "modal.img_paste.filename_label": "Nom du fichier",
@@ -275,6 +283,11 @@
275
283
  "admin.extra_files.added_label": "Ajouté",
276
284
  "admin.extra_files.loading": "Chargement…",
277
285
 
286
+ "admin.files.title": "Pièces jointes",
287
+ "admin.files.description": "Extensions refusées par le endpoint <code>/api/files/upload</code>. Une extension par ligne (ou séparées par des virgules ou des espaces), sans le point.",
288
+ "admin.files.blocked_label": "Extensions bloquées",
289
+ "admin.files.hint": "Par défaut, les formats exécutables courants. Taille maximale : 19 Mo par fichier.",
290
+
278
291
  "admin.palette.title": "Palettes de couleurs des diagrammes",
279
292
  "admin.palette.description": "Personnalisez les couleurs disponibles dans l'éditeur de diagrammes. Les modifications prennent effet après enregistrement.",
280
293
  "admin.palette.shapes_title": "Couleurs des formes",