@neuctra/notlix-mcp 1.0.0 → 2.0.1
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 +51 -26
- package/bin/notlix-mcp.js +79 -21
- package/package.json +13 -3
- package/src/authix.js +153 -0
- package/src/cli/prompt.js +27 -0
- package/src/cli/session.js +37 -0
- package/src/content.js +263 -0
- package/src/defaults.js +8 -0
- package/src/documents.js +133 -0
- package/src/http.js +6 -35
- package/src/index.js +7 -3
- package/src/server.js +185 -97
- package/src/shared/aiEdits.js +142 -0
- package/src/shared/contextPacks.js +133 -0
- package/src/shared/diagrams.js +451 -0
- package/src/shared/presentations.js +374 -0
- package/src/shared/records.js +32 -0
- package/src/shared/slideShapes.js +133 -0
- package/src/sharedText.js +61 -0
- package/src/connection.js +0 -15
- package/src/inbox.js +0 -60
- package/src/seal.js +0 -32
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Copied from neuctra-notlix-client/lib/workspace/presentations.js by scripts/sync.mjs. Don't edit here.
|
|
2
|
+
|
|
3
|
+
// The presentation model. A deck is JSON, not HTML: every slide is a list of
|
|
4
|
+
// positioned elements on a fixed 960x540 stage, and text is always rendered as
|
|
5
|
+
// plain text. Colors and fonts left unset come from the theme, so changing the
|
|
6
|
+
// theme restyles the whole deck.
|
|
7
|
+
|
|
8
|
+
export const STAGE = { width: 960, height: 540 };
|
|
9
|
+
export const MIN_ELEMENT_SIZE = 24;
|
|
10
|
+
export const UNTITLED_PRESENTATION = "Untitled presentation";
|
|
11
|
+
export const MAX_TABLE_ROWS = 12;
|
|
12
|
+
export const MAX_TABLE_COLUMNS = 8;
|
|
13
|
+
|
|
14
|
+
const SNAP_DISTANCE = 6;
|
|
15
|
+
|
|
16
|
+
export const THEMES = [
|
|
17
|
+
{ id: "paper", name: "Paper", background: "#ffffff", title: "#14231a", body: "#3b4a40", accent: "#0f9d3a", font: "Arial" },
|
|
18
|
+
{ id: "ink", name: "Ink", background: "#10141a", title: "#ffffff", body: "#c7ced8", accent: "#34d399", font: "Arial" },
|
|
19
|
+
{ id: "sand", name: "Sand", background: "#f6efe3", title: "#3a2a1c", body: "#5b4a39", accent: "#c2692d", font: "Georgia" },
|
|
20
|
+
{ id: "ocean", name: "Ocean", background: "#0b2a45", title: "#ffffff", body: "#c9dcec", accent: "#4cc9f0", font: "Verdana" },
|
|
21
|
+
{ id: "plum", name: "Plum", background: "#2b1733", title: "#fdf4ff", body: "#dcc8e4", accent: "#e879f9", font: "Georgia" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const getTheme = (themeId) => THEMES.find((theme) => theme.id === themeId) || THEMES[0];
|
|
25
|
+
|
|
26
|
+
const newId = () => crypto.randomUUID().slice(0, 8);
|
|
27
|
+
|
|
28
|
+
// Text inside a shape starts in the background color, so it reads on the fill.
|
|
29
|
+
const SHAPE_TEXT = {
|
|
30
|
+
text: "",
|
|
31
|
+
placeholder: "",
|
|
32
|
+
role: "background",
|
|
33
|
+
fontSize: 20,
|
|
34
|
+
bold: false,
|
|
35
|
+
italic: false,
|
|
36
|
+
align: "center",
|
|
37
|
+
valign: "middle",
|
|
38
|
+
bullets: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SHAPE_STYLE = { fill: null, opacity: 1, stroke: null, strokeWidth: 0 };
|
|
42
|
+
|
|
43
|
+
export function createElement(type, fields = {}) {
|
|
44
|
+
const base = { id: newId(), type, x: 330, y: 200, w: 300, h: 140 };
|
|
45
|
+
|
|
46
|
+
if (type === "text") {
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
h: 60,
|
|
50
|
+
text: "",
|
|
51
|
+
placeholder: "Type something",
|
|
52
|
+
role: "body",
|
|
53
|
+
fontSize: 22,
|
|
54
|
+
bold: false,
|
|
55
|
+
italic: false,
|
|
56
|
+
align: "left",
|
|
57
|
+
valign: "top",
|
|
58
|
+
bullets: false,
|
|
59
|
+
...fields,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type === "shape") return { ...base, ...SHAPE_TEXT, ...SHAPE_STYLE, shape: "rect", ...fields };
|
|
64
|
+
|
|
65
|
+
if (type === "line") {
|
|
66
|
+
return {
|
|
67
|
+
id: base.id,
|
|
68
|
+
type,
|
|
69
|
+
x1: 360,
|
|
70
|
+
y1: 270,
|
|
71
|
+
x2: 600,
|
|
72
|
+
y2: 270,
|
|
73
|
+
stroke: null,
|
|
74
|
+
strokeWidth: 3,
|
|
75
|
+
dash: "solid",
|
|
76
|
+
startArrow: false,
|
|
77
|
+
endArrow: false,
|
|
78
|
+
...fields,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (type === "table") {
|
|
83
|
+
const { rowCount = 3, columnCount = 3, ...rest } = fields;
|
|
84
|
+
const h = rowCount * 44;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
x: 160,
|
|
89
|
+
y: (STAGE.height - h) / 2,
|
|
90
|
+
w: 640,
|
|
91
|
+
h,
|
|
92
|
+
rows: Array.from({ length: rowCount }, () => Array(columnCount).fill("")),
|
|
93
|
+
header: true,
|
|
94
|
+
fontSize: 16,
|
|
95
|
+
...rest,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { ...base, src: "", ...fields };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const title = (fields) =>
|
|
103
|
+
createElement("text", { role: "title", placeholder: "Click to add title", bold: true, ...fields });
|
|
104
|
+
|
|
105
|
+
const body = (fields) =>
|
|
106
|
+
createElement("text", { placeholder: "Click to add text", bullets: true, ...fields });
|
|
107
|
+
|
|
108
|
+
export const LAYOUTS = [
|
|
109
|
+
{
|
|
110
|
+
id: "title",
|
|
111
|
+
name: "Title slide",
|
|
112
|
+
build: () => [
|
|
113
|
+
title({ x: 80, y: 170, w: 800, h: 120, fontSize: 48, align: "center", valign: "bottom" }),
|
|
114
|
+
body({ x: 80, y: 305, w: 800, h: 60, fontSize: 24, align: "center", bullets: false, placeholder: "Click to add subtitle" }),
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "content",
|
|
119
|
+
name: "Title and content",
|
|
120
|
+
build: () => [
|
|
121
|
+
title({ x: 60, y: 36, w: 840, h: 80, fontSize: 36, valign: "middle" }),
|
|
122
|
+
body({ x: 60, y: 130, w: 840, h: 360 }),
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "section",
|
|
127
|
+
name: "Section header",
|
|
128
|
+
build: () => [
|
|
129
|
+
createElement("shape", { x: 60, y: 210, w: 8, h: 120 }),
|
|
130
|
+
title({ x: 90, y: 210, w: 790, h: 120, fontSize: 44, valign: "middle" }),
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "columns",
|
|
135
|
+
name: "Two columns",
|
|
136
|
+
build: () => [
|
|
137
|
+
title({ x: 60, y: 36, w: 840, h: 80, fontSize: 36, valign: "middle" }),
|
|
138
|
+
body({ x: 60, y: 130, w: 410, h: 360 }),
|
|
139
|
+
body({ x: 490, y: 130, w: 410, h: 360 }),
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
{ id: "blank", name: "Blank", build: () => [] },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export function createSlide(layoutId = "content") {
|
|
146
|
+
const layout = LAYOUTS.find((option) => option.id === layoutId) || LAYOUTS[1];
|
|
147
|
+
return { id: newId(), notes: "", elements: layout.build() };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A copy that sits slightly offset, so it's visible on top of the original. */
|
|
151
|
+
export function duplicateElement(element) {
|
|
152
|
+
return { ...element, ...nudged(element, 16, 16), id: newId() };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The fields that move an element. Lines have two ends, not a box. */
|
|
156
|
+
export function nudged(element, dx, dy) {
|
|
157
|
+
if (element.type === "line") {
|
|
158
|
+
return {
|
|
159
|
+
x1: element.x1 + dx,
|
|
160
|
+
y1: element.y1 + dy,
|
|
161
|
+
x2: element.x2 + dx,
|
|
162
|
+
y2: element.y2 + dy,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { x: element.x + dx, y: element.y + dy };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const hasTextContent = (element) => ["text", "shape"].includes(element?.type);
|
|
170
|
+
|
|
171
|
+
export function lineBox({ x1, y1, x2, y2 }) {
|
|
172
|
+
return {
|
|
173
|
+
x: Math.min(x1, x2),
|
|
174
|
+
y: Math.min(y1, y2),
|
|
175
|
+
w: Math.abs(x2 - x1),
|
|
176
|
+
h: Math.abs(y2 - y1),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Moves a whole line, always leaving some of it on the slide. */
|
|
181
|
+
export function moveLine(line, dx, dy) {
|
|
182
|
+
const box = lineBox(line);
|
|
183
|
+
|
|
184
|
+
return nudged(
|
|
185
|
+
line,
|
|
186
|
+
Math.min(Math.max(dx, MIN_ELEMENT_SIZE - box.x - box.w), STAGE.width - MIN_ELEMENT_SIZE - box.x),
|
|
187
|
+
Math.min(Math.max(dy, MIN_ELEMENT_SIZE - box.y - box.h), STAGE.height - MIN_ELEMENT_SIZE - box.y),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Moves one end. `straighten` snaps the angle to the nearest 45 degrees. */
|
|
192
|
+
export function moveLineEnd(line, end, dx, dy, straighten) {
|
|
193
|
+
const [movedX, movedY, fixedX, fixedY] =
|
|
194
|
+
end === "start" ? ["x1", "y1", "x2", "y2"] : ["x2", "y2", "x1", "y1"];
|
|
195
|
+
|
|
196
|
+
let x = line[movedX] + dx;
|
|
197
|
+
let y = line[movedY] + dy;
|
|
198
|
+
|
|
199
|
+
if (straighten) {
|
|
200
|
+
const step = Math.PI / 4;
|
|
201
|
+
const angle = Math.round(Math.atan2(y - line[fixedY], x - line[fixedX]) / step) * step;
|
|
202
|
+
const length = Math.hypot(x - line[fixedX], y - line[fixedY]);
|
|
203
|
+
x = line[fixedX] + length * Math.cos(angle);
|
|
204
|
+
y = line[fixedY] + length * Math.sin(angle);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { [movedX]: x, [movedY]: y };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Rows keep their height, so the table grows and shrinks with them. */
|
|
211
|
+
export function resizeTable(table, { rowChange = 0, columnChange = 0 }) {
|
|
212
|
+
const rowHeight = table.h / table.rows.length;
|
|
213
|
+
let rows = table.rows;
|
|
214
|
+
|
|
215
|
+
if (rowChange > 0) rows = [...rows, Array(rows[0].length).fill("")];
|
|
216
|
+
if (rowChange < 0) rows = rows.slice(0, -1);
|
|
217
|
+
if (columnChange > 0) rows = rows.map((row) => [...row, ""]);
|
|
218
|
+
if (columnChange < 0) rows = rows.map((row) => row.slice(0, -1));
|
|
219
|
+
|
|
220
|
+
return { rows, h: rowHeight * rows.length };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function duplicateSlide(slide) {
|
|
224
|
+
return {
|
|
225
|
+
...slide,
|
|
226
|
+
id: newId(),
|
|
227
|
+
elements: slide.elements.map((element) => ({ ...element, id: newId() })),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Records can come from anywhere, so a deck always loads with at least one slide. */
|
|
232
|
+
export function normalizeSlides(slides) {
|
|
233
|
+
if (!Array.isArray(slides) || slides.length === 0) return [createSlide("title")];
|
|
234
|
+
|
|
235
|
+
// Shapes saved before they could hold text get the newer fields.
|
|
236
|
+
return slides.map((slide) => ({
|
|
237
|
+
...slide,
|
|
238
|
+
elements: slide.elements.map((element) =>
|
|
239
|
+
element.type === "shape" ? { ...SHAPE_TEXT, ...SHAPE_STYLE, ...element } : element,
|
|
240
|
+
),
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const BULLET = /^(?:[-*+]|\d+[.)])\s+/;
|
|
245
|
+
const NOTES = /^notes?:\s*/i;
|
|
246
|
+
|
|
247
|
+
// Slides show plain text, so inline markdown would appear as literal symbols.
|
|
248
|
+
const plainText = (line) =>
|
|
249
|
+
line
|
|
250
|
+
.replace(/^#+\s*/, "")
|
|
251
|
+
.replace(BULLET, "")
|
|
252
|
+
.replace(/\*\*|__|`/g, "")
|
|
253
|
+
.trim();
|
|
254
|
+
|
|
255
|
+
function bodyFontSize(lineCount) {
|
|
256
|
+
if (lineCount <= 7) return 22;
|
|
257
|
+
return lineCount <= 10 ? 18 : 14;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseSlide(source, isFirst) {
|
|
261
|
+
const lines = source
|
|
262
|
+
.split("\n")
|
|
263
|
+
.map((line) => line.trim())
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
if (lines.length === 0) return null;
|
|
266
|
+
|
|
267
|
+
const notesAt = lines.findIndex((line) => NOTES.test(line));
|
|
268
|
+
const content = notesAt === -1 ? lines : lines.slice(0, notesAt);
|
|
269
|
+
const notes = notesAt === -1 ? "" : lines.slice(notesAt).join("\n").replace(NOTES, "");
|
|
270
|
+
|
|
271
|
+
const hasHeading = content[0]?.startsWith("#");
|
|
272
|
+
const heading = hasHeading ? plainText(content[0]) : "";
|
|
273
|
+
const bodyLines = hasHeading ? content.slice(1) : content;
|
|
274
|
+
|
|
275
|
+
let layout = "content";
|
|
276
|
+
if (heading && bodyLines.length === 0) layout = isFirst ? "title" : "section";
|
|
277
|
+
if (heading && bodyLines.length === 1 && isFirst) layout = "title";
|
|
278
|
+
|
|
279
|
+
const slide = createSlide(layout);
|
|
280
|
+
slide.notes = notes;
|
|
281
|
+
|
|
282
|
+
for (const element of slide.elements) {
|
|
283
|
+
if (element.role === "title") element.text = heading;
|
|
284
|
+
if (element.role === "body") {
|
|
285
|
+
element.text = bodyLines.map(plainText).join("\n");
|
|
286
|
+
if (layout === "content") {
|
|
287
|
+
element.fontSize = bodyFontSize(bodyLines.length);
|
|
288
|
+
element.bullets = bodyLines.some((line) => BULLET.test(line));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return slide;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Builds slides from markdown, such as an AI's: --- splits slides, # starts a
|
|
298
|
+
* title, - starts a bullet, and Notes: starts speaker notes.
|
|
299
|
+
*/
|
|
300
|
+
export function markdownToSlides(markdown) {
|
|
301
|
+
const slides = String(markdown)
|
|
302
|
+
.split(/^\s*-{3,}\s*$/m)
|
|
303
|
+
.filter((source) => source.trim())
|
|
304
|
+
.map((source, index) => parseSlide(source, index === 0));
|
|
305
|
+
|
|
306
|
+
return normalizeSlides(slides.filter(Boolean));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Plain text for card search. */
|
|
310
|
+
export function slidesText(slides) {
|
|
311
|
+
return slides
|
|
312
|
+
.flatMap((slide) => [
|
|
313
|
+
...slide.elements.flatMap((element) => element.rows?.flat() ?? element.text),
|
|
314
|
+
slide.notes,
|
|
315
|
+
])
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join(" ");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Keeps an element reachable, so it can't be dragged fully off the slide. */
|
|
321
|
+
function keepOnStage(box) {
|
|
322
|
+
return {
|
|
323
|
+
...box,
|
|
324
|
+
x: Math.min(Math.max(box.x, MIN_ELEMENT_SIZE - box.w), STAGE.width - MIN_ELEMENT_SIZE),
|
|
325
|
+
y: Math.min(Math.max(box.y, MIN_ELEMENT_SIZE - box.h), STAGE.height - MIN_ELEMENT_SIZE),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Moves a box, snapping its center to the slide's center lines. */
|
|
330
|
+
export function moveBox(box, dx, dy) {
|
|
331
|
+
let x = box.x + dx;
|
|
332
|
+
let y = box.y + dy;
|
|
333
|
+
const guides = { vertical: false, horizontal: false };
|
|
334
|
+
|
|
335
|
+
if (Math.abs(x + box.w / 2 - STAGE.width / 2) < SNAP_DISTANCE) {
|
|
336
|
+
x = (STAGE.width - box.w) / 2;
|
|
337
|
+
guides.vertical = true;
|
|
338
|
+
}
|
|
339
|
+
if (Math.abs(y + box.h / 2 - STAGE.height / 2) < SNAP_DISTANCE) {
|
|
340
|
+
y = (STAGE.height - box.h) / 2;
|
|
341
|
+
guides.horizontal = true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { box: keepOnStage({ ...box, x, y }), guides };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `handle` is a compass direction, such as "se". Corners can keep the shape's ratio. */
|
|
348
|
+
export function resizeFreely(box, handle, dx, dy, keepRatio) {
|
|
349
|
+
let { x, y, w, h } = box;
|
|
350
|
+
|
|
351
|
+
if (handle.includes("e")) w = Math.max(MIN_ELEMENT_SIZE, w + dx);
|
|
352
|
+
if (handle.includes("s")) h = Math.max(MIN_ELEMENT_SIZE, h + dy);
|
|
353
|
+
if (handle.includes("w")) {
|
|
354
|
+
w = Math.max(MIN_ELEMENT_SIZE, box.w - dx);
|
|
355
|
+
x = box.x + box.w - w;
|
|
356
|
+
}
|
|
357
|
+
if (handle.includes("n")) {
|
|
358
|
+
h = Math.max(MIN_ELEMENT_SIZE, box.h - dy);
|
|
359
|
+
y = box.y + box.h - h;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (keepRatio && handle.length === 2) {
|
|
363
|
+
h = Math.max(MIN_ELEMENT_SIZE, w * (box.h / box.w));
|
|
364
|
+
w = h * (box.w / box.h);
|
|
365
|
+
if (handle.includes("n")) y = box.y + box.h - h;
|
|
366
|
+
if (handle.includes("w")) x = box.x + box.w - w;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { ...box, x, y, w, h };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function resizeBox(box, handle, dx, dy, keepRatio) {
|
|
373
|
+
return keepOnStage(resizeFreely(box, handle, dx, dy, keepRatio));
|
|
374
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copied from neuctra-notlix-client/lib/data/records.js by scripts/sync.mjs. Don't edit here.
|
|
2
|
+
|
|
3
|
+
// Shared helpers for Authix list responses.
|
|
4
|
+
|
|
5
|
+
export function itemsOf(response) {
|
|
6
|
+
if (Array.isArray(response)) return response;
|
|
7
|
+
if (Array.isArray(response?.data)) return response.data;
|
|
8
|
+
if (Array.isArray(response?.items)) return response.items;
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function nextCursorOf(response) {
|
|
13
|
+
return (
|
|
14
|
+
response?.next_cursor ||
|
|
15
|
+
response?.nextCursor ||
|
|
16
|
+
response?.pagination?.next_cursor ||
|
|
17
|
+
response?.pagination?.nextCursor ||
|
|
18
|
+
null
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Follows the cursor so a list is never cut off at one page. */
|
|
23
|
+
export async function fetchAllPages(fetchPage) {
|
|
24
|
+
const all = [];
|
|
25
|
+
let cursor;
|
|
26
|
+
do {
|
|
27
|
+
const response = await fetchPage(cursor);
|
|
28
|
+
all.push(...itemsOf(response));
|
|
29
|
+
cursor = nextCursorOf(response);
|
|
30
|
+
} while (cursor);
|
|
31
|
+
return all;
|
|
32
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copied from neuctra-notlix-client/lib/workspace/slideShapes.js by scripts/sync.mjs. Don't edit here.
|
|
2
|
+
|
|
3
|
+
// The shape catalog. Each id is PowerPoint's own preset name, so a shape exports
|
|
4
|
+
// as a real, editable shape. The outlines follow PowerPoint's default geometry.
|
|
5
|
+
|
|
6
|
+
const polygon = (points) => (w, h) =>
|
|
7
|
+
`M${points.map(([x, y]) => `${(x * w) / 100} ${(y * h) / 100}`).join("L")}Z`;
|
|
8
|
+
|
|
9
|
+
function roundRect(w, h) {
|
|
10
|
+
const r = Math.min(w, h) / 6;
|
|
11
|
+
return `M${r} 0H${w - r}A${r} ${r} 0 0 1 ${w} ${r}V${h - r}A${r} ${r} 0 0 1 ${w - r} ${h}H${r}A${r} ${r} 0 0 1 0 ${h - r}V${r}A${r} ${r} 0 0 1 ${r} 0Z`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ellipse(w, h) {
|
|
15
|
+
return `M0 ${h / 2}A${w / 2} ${h / 2} 0 1 1 ${w} ${h / 2}A${w / 2} ${h / 2} 0 1 1 0 ${h / 2}Z`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function star(w, h) {
|
|
19
|
+
const points = Array.from({ length: 10 }, (_, index) => {
|
|
20
|
+
const radius = index % 2 === 0 ? 50 : 19;
|
|
21
|
+
const angle = (Math.PI / 5) * index - Math.PI / 2;
|
|
22
|
+
return [50 + radius * Math.cos(angle), 50 + radius * Math.sin(angle)];
|
|
23
|
+
});
|
|
24
|
+
return polygon(points)(w, h);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function heart(w, h) {
|
|
28
|
+
const x = (value) => (value * w) / 100;
|
|
29
|
+
const y = (value) => (value * h) / 100;
|
|
30
|
+
return `M${x(50)} ${y(25)}C${x(65)} ${y(-8)} ${x(115)} ${y(5)} ${x(92)} ${y(45)}C${x(83)} ${y(62)} ${x(62)} ${y(82)} ${x(50)} ${y(100)}C${x(38)} ${y(82)} ${x(17)} ${y(62)} ${x(8)} ${y(45)}C${x(-15)} ${y(5)} ${x(35)} ${y(-8)} ${x(50)} ${y(25)}Z`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function speechBubble(w, h) {
|
|
34
|
+
const r = Math.min(w, h) / 6;
|
|
35
|
+
return `M${r} 0H${w - r}A${r} ${r} 0 0 1 ${w} ${r}V${h - r}A${r} ${r} 0 0 1 ${w - r} ${h}H${w * 0.417}L${w * 0.292} ${h * 1.125}L${w * 0.167} ${h}H${r}A${r} ${r} 0 0 1 0 ${h - r}V${r}A${r} ${r} 0 0 1 ${r} 0Z`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Arrow heads keep their size as the shape stretches, as they do in PowerPoint. */
|
|
39
|
+
const arrow = (draw) => (w, h) => {
|
|
40
|
+
const head = Math.min(w, h) / 2;
|
|
41
|
+
return `M${draw(w, h, head).map((point) => point.join(" ")).join("L")}Z`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const SHAPES = [
|
|
45
|
+
{ id: "rect", name: "Rectangle", group: "Basic", draw: polygon([[0, 0], [100, 0], [100, 100], [0, 100]]) },
|
|
46
|
+
{ id: "roundRect", name: "Rounded rectangle", group: "Basic", draw: roundRect },
|
|
47
|
+
{ id: "ellipse", name: "Ellipse", group: "Basic", draw: ellipse },
|
|
48
|
+
{ id: "triangle", name: "Triangle", group: "Basic", draw: polygon([[50, 0], [100, 100], [0, 100]]) },
|
|
49
|
+
{ id: "rtTriangle", name: "Right triangle", group: "Basic", draw: polygon([[0, 0], [100, 100], [0, 100]]) },
|
|
50
|
+
{ id: "diamond", name: "Diamond", group: "Basic", draw: polygon([[50, 0], [100, 50], [50, 100], [0, 50]]) },
|
|
51
|
+
{ id: "parallelogram", name: "Parallelogram", group: "Basic", draw: polygon([[25, 0], [100, 0], [75, 100], [0, 100]]) },
|
|
52
|
+
{ id: "trapezoid", name: "Trapezoid", group: "Basic", draw: polygon([[25, 0], [75, 0], [100, 100], [0, 100]]) },
|
|
53
|
+
{ id: "pentagon", name: "Pentagon", group: "Basic", draw: polygon([[50, 0], [100, 38], [81, 100], [19, 100], [0, 38]]) },
|
|
54
|
+
{ id: "hexagon", name: "Hexagon", group: "Basic", draw: polygon([[25, 0], [75, 0], [100, 50], [75, 100], [25, 100], [0, 50]]) },
|
|
55
|
+
{ id: "octagon", name: "Octagon", group: "Basic", draw: polygon([[29, 0], [71, 0], [100, 29], [100, 71], [71, 100], [29, 100], [0, 71], [0, 29]]) },
|
|
56
|
+
{ id: "star5", name: "Star", group: "Basic", draw: star },
|
|
57
|
+
{ id: "plus", name: "Cross", group: "Basic", draw: polygon([[25, 0], [75, 0], [75, 25], [100, 25], [100, 75], [75, 75], [75, 100], [25, 100], [25, 75], [0, 75], [0, 25], [25, 25]]) },
|
|
58
|
+
{ id: "heart", name: "Heart", group: "Basic", draw: heart },
|
|
59
|
+
{
|
|
60
|
+
id: "rightArrow",
|
|
61
|
+
name: "Right arrow",
|
|
62
|
+
group: "Arrows",
|
|
63
|
+
draw: arrow((w, h, head) => [[0, h / 4], [w - head, h / 4], [w - head, 0], [w, h / 2], [w - head, h], [w - head, (h * 3) / 4], [0, (h * 3) / 4]]),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "leftArrow",
|
|
67
|
+
name: "Left arrow",
|
|
68
|
+
group: "Arrows",
|
|
69
|
+
draw: arrow((w, h, head) => [[w, h / 4], [head, h / 4], [head, 0], [0, h / 2], [head, h], [head, (h * 3) / 4], [w, (h * 3) / 4]]),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "upArrow",
|
|
73
|
+
name: "Up arrow",
|
|
74
|
+
group: "Arrows",
|
|
75
|
+
draw: arrow((w, h, head) => [[w / 4, h], [w / 4, head], [0, head], [w / 2, 0], [w, head], [(w * 3) / 4, head], [(w * 3) / 4, h]]),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "downArrow",
|
|
79
|
+
name: "Down arrow",
|
|
80
|
+
group: "Arrows",
|
|
81
|
+
draw: arrow((w, h, head) => [[w / 4, 0], [w / 4, h - head], [0, h - head], [w / 2, h], [w, h - head], [(w * 3) / 4, h - head], [(w * 3) / 4, 0]]),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "leftRightArrow",
|
|
85
|
+
name: "Double arrow",
|
|
86
|
+
group: "Arrows",
|
|
87
|
+
draw: arrow((w, h, head) => [[0, h / 2], [head, 0], [head, h / 4], [w - head, h / 4], [w - head, 0], [w, h / 2], [w - head, h], [w - head, (h * 3) / 4], [head, (h * 3) / 4], [head, h]]),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "chevron",
|
|
91
|
+
name: "Chevron",
|
|
92
|
+
group: "Arrows",
|
|
93
|
+
draw: arrow((w, h, head) => [[0, 0], [w - head, 0], [w, h / 2], [w - head, h], [0, h], [head, h / 2]]),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "homePlate",
|
|
97
|
+
name: "Tag arrow",
|
|
98
|
+
group: "Arrows",
|
|
99
|
+
draw: arrow((w, h, head) => [[0, 0], [w - head, 0], [w, h / 2], [w - head, h], [0, h]]),
|
|
100
|
+
},
|
|
101
|
+
{ id: "wedgeRoundRectCallout", name: "Speech bubble", group: "Callouts", draw: speechBubble },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
export const SHAPE_GROUPS = ["Basic", "Arrows", "Callouts"];
|
|
105
|
+
|
|
106
|
+
// These read better wide. The rest start square, so a star isn't squashed.
|
|
107
|
+
const WIDE_SHAPES = new Set([
|
|
108
|
+
"rect",
|
|
109
|
+
"roundRect",
|
|
110
|
+
"parallelogram",
|
|
111
|
+
"rightArrow",
|
|
112
|
+
"leftArrow",
|
|
113
|
+
"leftRightArrow",
|
|
114
|
+
"chevron",
|
|
115
|
+
"homePlate",
|
|
116
|
+
"wedgeRoundRectCallout",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** The size a new shape starts at. */
|
|
120
|
+
export const shapeSize = (shapeId) =>
|
|
121
|
+
WIDE_SHAPES.has(shapeId) ? { w: 280, h: 130 } : { w: 180, h: 180 };
|
|
122
|
+
|
|
123
|
+
/** The three corners of an arrow head pointing at (toX, toY). */
|
|
124
|
+
export function arrowHeadPoints(fromX, fromY, toX, toY, size) {
|
|
125
|
+
const angle = Math.atan2(toY - fromY, toX - fromX);
|
|
126
|
+
const corner = (turn) =>
|
|
127
|
+
`${toX - size * Math.cos(angle + turn)},${toY - size * Math.sin(angle + turn)}`;
|
|
128
|
+
|
|
129
|
+
return `${toX},${toY} ${corner(0.45)} ${corner(-0.45)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const shapePath = (shapeId, w, h) =>
|
|
133
|
+
(SHAPES.find((shape) => shape.id === shapeId) || SHAPES[0]).draw(w, h);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { documentToReadable } from "./documents.js";
|
|
2
|
+
import { compilePack, noteBody } from "./shared/contextPacks.js";
|
|
3
|
+
|
|
4
|
+
// Plain text of a live-shared item, for "Link for AI": an AI that fetches the
|
|
5
|
+
// link gets readable markdown instead of a page that needs JavaScript. The
|
|
6
|
+
// same token as the website's /note/share#<token> link, so it's just as live
|
|
7
|
+
// and stops working when the owner stops sharing.
|
|
8
|
+
|
|
9
|
+
// From LIVE_SHARE_CATEGORIES in the client's lib/sharing/liveShare.js.
|
|
10
|
+
export const SHAREABLE = new Set(["note", "document", "knowledge", "contextpack"]);
|
|
11
|
+
|
|
12
|
+
export const SHARE_TOKEN = /^[A-Za-z0-9_-]{8,200}$/;
|
|
13
|
+
|
|
14
|
+
const day = (value) => (value ? String(value).slice(0, 10) : "");
|
|
15
|
+
|
|
16
|
+
/** An AI Brief's sources, fetched by their own tokens, like fetchBriefParts in the client. */
|
|
17
|
+
async function briefParts(authix, pack) {
|
|
18
|
+
const tokens = pack.sourceTokens || {};
|
|
19
|
+
const library = [];
|
|
20
|
+
for (const source of pack.sources || []) {
|
|
21
|
+
const token = tokens[source.id];
|
|
22
|
+
const record = token ? await authix.getShared(token).catch(() => null) : null;
|
|
23
|
+
if (!record || record.dataCategory !== source.type) continue;
|
|
24
|
+
library.push({
|
|
25
|
+
type: source.type,
|
|
26
|
+
id: source.id,
|
|
27
|
+
title: record.title || "Untitled",
|
|
28
|
+
body: source.type === "note" ? noteBody(record) : String(record.content || ""),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const profile =
|
|
32
|
+
pack.includeProfile && pack.profileToken ? await authix.getShared(pack.profileToken).catch(() => null) : null;
|
|
33
|
+
return { library, profile };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `{ title, text }` for a share token, or null when it isn't shared (or isn't
|
|
38
|
+
* a type that can be). `authix` is an AuthixClient; no session is needed.
|
|
39
|
+
*/
|
|
40
|
+
export async function sharedItemText(authix, token) {
|
|
41
|
+
if (!SHARE_TOKEN.test(String(token || ""))) return null;
|
|
42
|
+
const record = await authix.getShared(token);
|
|
43
|
+
if (!record || !SHAREABLE.has(record.dataCategory)) return null;
|
|
44
|
+
|
|
45
|
+
const updated = day(record.updatedAt || record.createdAt);
|
|
46
|
+
const footer = `\n\n---\nShared from Neuctra Notlix${updated ? `, last updated ${updated}` : ""}. This link always shows the latest version.\n`;
|
|
47
|
+
|
|
48
|
+
if (record.dataCategory === "contextpack") {
|
|
49
|
+
const { markdown, missing } = compilePack(record, await briefParts(authix, record));
|
|
50
|
+
const gap = missing ? `\n\n(${missing} source${missing === 1 ? " isn't" : "s aren't"} available.)` : "";
|
|
51
|
+
return { title: record.name || "AI Brief", text: `${markdown.trim()}${gap}${footer}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const title = record.title || "Untitled";
|
|
55
|
+
let body = "";
|
|
56
|
+
if (record.dataCategory === "note") body = noteBody(record);
|
|
57
|
+
else if (record.dataCategory === "knowledge") body = String(record.content || "");
|
|
58
|
+
else body = documentToReadable(record.html || "") || String(record.text || "");
|
|
59
|
+
|
|
60
|
+
return { title, text: `# ${title}\n\n${body.trim()}${footer}` };
|
|
61
|
+
}
|
package/src/connection.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
// The connection token the Notlix dashboard gives out under Connect AI:
|
|
2
|
-
// `<inbox id>.<write secret>`. Older connections handed out a full URL ending
|
|
3
|
-
// in /mcp/<id>.<secret>; that works too.
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_API_URL = "https://server.notlix.neuctra.com";
|
|
6
|
-
|
|
7
|
-
const TOKEN = /^([0-9a-f]{32})\.([0-9a-f]{64})$/;
|
|
8
|
-
|
|
9
|
-
/** `{ id, secret }` from a token or an old MCP URL, or null when it isn't one. */
|
|
10
|
-
export function parseConnection(value) {
|
|
11
|
-
const text = String(value || "").trim();
|
|
12
|
-
const token = text.includes("/mcp/") ? text.split("/mcp/").pop().split(/[/?#]/)[0] : text;
|
|
13
|
-
const match = TOKEN.exec(token);
|
|
14
|
-
return match ? { id: match[1], secret: match[2] } : null;
|
|
15
|
-
}
|
package/src/inbox.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { seal } from "./seal.js";
|
|
2
|
-
|
|
3
|
-
// Talks to the Notlix server's inbox API with the write token. It can fetch the
|
|
4
|
-
// inbox's public key and add encrypted items. It can't read anything.
|
|
5
|
-
|
|
6
|
-
/** An error whose message is safe to show the AI and the user. */
|
|
7
|
-
export class InboxError extends Error {
|
|
8
|
-
constructor(message, status) {
|
|
9
|
-
super(message);
|
|
10
|
-
this.name = "InboxError";
|
|
11
|
-
this.status = status;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const UNREACHABLE = "Couldn't reach Notlix. Check the connection and try again.";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* `apiUrl`: the Notlix server. `fetch`: optional, for a Cloudflare service
|
|
19
|
-
* binding or tests. The public key is fetched once and kept.
|
|
20
|
-
*/
|
|
21
|
-
export function createInboxClient({ apiUrl, id, secret, fetch: doFetch = globalThis.fetch }) {
|
|
22
|
-
const base = String(apiUrl).replace(/\/+$/, "");
|
|
23
|
-
const headers = { Authorization: `Bearer ${secret}` };
|
|
24
|
-
let publicKey = null;
|
|
25
|
-
|
|
26
|
-
async function call(path, init = {}) {
|
|
27
|
-
let res;
|
|
28
|
-
try {
|
|
29
|
-
res = await doFetch(`${base}/v1/inboxes/${id}${path}`, {
|
|
30
|
-
...init,
|
|
31
|
-
headers: { ...headers, ...init.headers },
|
|
32
|
-
});
|
|
33
|
-
} catch {
|
|
34
|
-
throw new InboxError(UNREACHABLE, 0);
|
|
35
|
-
}
|
|
36
|
-
if (res.ok) return res.json();
|
|
37
|
-
const body = await res.json().catch(() => null);
|
|
38
|
-
if (res.status === 401) publicKey = null;
|
|
39
|
-
throw new InboxError(body?.error?.message || UNREACHABLE, res.status);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function getPublicKey() {
|
|
43
|
-
if (!publicKey) publicKey = (await call("/key")).publicKey;
|
|
44
|
-
return publicKey;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
getPublicKey,
|
|
49
|
-
/** Encrypts `item` here and adds it to the inbox. Returns the item id. */
|
|
50
|
-
async add(item) {
|
|
51
|
-
const sealed = await seal(await getPublicKey(), item);
|
|
52
|
-
const { id: itemId } = await call("/items", {
|
|
53
|
-
method: "POST",
|
|
54
|
-
headers: { "Content-Type": "application/json" },
|
|
55
|
-
body: JSON.stringify(sealed),
|
|
56
|
-
});
|
|
57
|
-
return itemId;
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|