@panphora/clayjs 1.13.1 → 1.15.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69385 -40992
- package/dist/server/checkpoint.cjs +1 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +127 -99
- package/entries/clay.js +22 -0
- package/entries/sap.js +5 -5
- package/package.json +16 -2
- package/packed-contract.json +18 -3
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +12 -3
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +154 -36
- package/src/core/snapshot.js +51 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/derived.js +116 -0
- package/src/lib/effect.js +130 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +47 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +16 -4
- package/src/loader.js +37 -1
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1170 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +386 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/derive.js +12 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +62 -4
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +36 -8
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import toast, { toastPersistent, dismissPersistent } from "../ui/toast.js";
|
|
2
|
+
import { state as pmState } from "../vendor/prosemirror.vendor.js";
|
|
3
|
+
|
|
4
|
+
export const NOTICE_TEXT = "Some text you typed was kept aside when its block moved or was removed.";
|
|
5
|
+
|
|
6
|
+
const STYLE_ID = "page-recover-style";
|
|
7
|
+
const STYLES = `
|
|
8
|
+
.page-recover { position: fixed; right: 16px; bottom: 16px; z-index: 20; min-height: 36px; padding: 0 14px; font: inherit; font-size: 14px; color: var(--page-ink); background: var(--page-pop-bg); border: 1px solid var(--page-chrome-line-rest); border-radius: 999px; cursor: pointer; }
|
|
9
|
+
.page-recover[hidden], .page-recover-panel[hidden] { display: none; }
|
|
10
|
+
.page-recover-panel { position: fixed; right: 16px; bottom: 64px; z-index: 21; width: min(380px, calc(100vw - 32px)); max-height: 60vh; overflow-y: auto; color: var(--page-ink); background: var(--page-pop-bg); border: 1px solid var(--page-chrome-line-rest); border-radius: var(--page-chrome-radius); padding: 8px; box-sizing: border-box; }
|
|
11
|
+
.page-recover-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 6px 8px; font-weight: 600; }
|
|
12
|
+
.page-recover-note { margin: 0 6px 8px; font-size: 13px; opacity: .72; }
|
|
13
|
+
.page-recover-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: center; min-height: 44px; padding: 8px 6px; border-top: 1px solid var(--page-chrome-line-rest); }
|
|
14
|
+
.page-recover-row.is-target { outline: 2px solid var(--page-accent, currentColor); outline-offset: -2px; }
|
|
15
|
+
.page-recover-meta { grid-column: 1; font-size: 12px; opacity: .72; }
|
|
16
|
+
.page-recover-text { grid-column: 1; overflow-wrap: anywhere; }
|
|
17
|
+
.page-recover-text mark { background: var(--page-mark-bg, rgba(255, 214, 102, .45)); color: inherit; border-radius: 2px; }
|
|
18
|
+
.page-recover-row button, .page-recover-head button { grid-column: 2; grid-row: 1 / span 2; min-height: 36px; padding: 0 12px; font: inherit; font-size: 14px; color: inherit; background: transparent; border: 1px solid var(--page-chrome-line-rest); border-radius: var(--page-chrome-radius); cursor: pointer; }
|
|
19
|
+
@media (max-width: 560px) {
|
|
20
|
+
.page-recover { top: 12px; bottom: auto; min-height: 44px; }
|
|
21
|
+
.page-recover-panel { left: 0; right: 0; bottom: 0; width: 100%; max-height: 70vh; border-radius: var(--page-chrome-radius) var(--page-chrome-radius) 0 0; }
|
|
22
|
+
.page-recover-row button, .page-recover-head button { min-height: 44px; }
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const TYPE_LABELS = { heading: "Heading", list_item: "List item", toggle: "Toggle", quote: "Quote", code_block: "Code", callout: "Callout", table: "Table", image: "Image" };
|
|
27
|
+
const PREVIEW_LIMIT = 280;
|
|
28
|
+
|
|
29
|
+
const el = (doc, tag, className, attrs = {}) => {
|
|
30
|
+
const node = doc.createElement(tag);
|
|
31
|
+
if (className) node.className = className;
|
|
32
|
+
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
33
|
+
return node;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function ensureStyle(doc) {
|
|
37
|
+
if (doc.getElementById(STYLE_ID)) return;
|
|
38
|
+
const style = doc.createElement("style");
|
|
39
|
+
style.id = STYLE_ID;
|
|
40
|
+
style.textContent = STYLES;
|
|
41
|
+
doc.head.append(style);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function previewOf(doc, record) {
|
|
45
|
+
const out = el(doc, "div", "page-recover-text");
|
|
46
|
+
let used = 0;
|
|
47
|
+
for (const block of record.blocks) {
|
|
48
|
+
for (const run of block.runs) {
|
|
49
|
+
if (run.kind !== "text" || used >= PREVIEW_LIMIT) continue;
|
|
50
|
+
const text = run.text.slice(0, PREVIEW_LIMIT - used);
|
|
51
|
+
used += text.length;
|
|
52
|
+
out.append(run.displaced ? Object.assign(doc.createElement("mark"), { textContent: text }) : doc.createTextNode(text));
|
|
53
|
+
}
|
|
54
|
+
if (used < PREVIEW_LIMIT && block !== record.blocks[record.blocks.length - 1]) out.append(doc.createTextNode(" / "));
|
|
55
|
+
}
|
|
56
|
+
if (!out.textContent) out.textContent = "(no text)";
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function authorsOf(record) {
|
|
61
|
+
const out = new Set();
|
|
62
|
+
for (const block of record.blocks) for (const run of block.runs) if (run.displaced) out.add(run.author ?? null);
|
|
63
|
+
return [...out];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function focusBlock(view, id) {
|
|
67
|
+
let at = null;
|
|
68
|
+
view.state.doc.forEach((node, pos) => { if (at === null && node.attrs.id === id) at = node.isTextblock ? pos + 1 : pos; });
|
|
69
|
+
if (at === null) return false;
|
|
70
|
+
view.dispatch(view.state.tr.setSelection(pmState.Selection.near(view.state.doc.resolve(at))).scrollIntoView());
|
|
71
|
+
view.focus();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createRecoverList({ binding, mount, ownTokens = [], onRestored = () => {}, onStale = () => {} }) {
|
|
76
|
+
const doc = mount.ownerDocument;
|
|
77
|
+
ensureStyle(doc);
|
|
78
|
+
const own = new Set(ownTokens.filter(Boolean));
|
|
79
|
+
const button = el(doc, "button", "page-recover", { type: "button", "aria-haspopup": "dialog", "aria-expanded": "false" });
|
|
80
|
+
const panel = el(doc, "div", "page-recover-panel", { role: "dialog", "aria-label": "Recover changes" });
|
|
81
|
+
button.hidden = true;
|
|
82
|
+
panel.hidden = true;
|
|
83
|
+
mount.append(button, panel);
|
|
84
|
+
|
|
85
|
+
let snapshot = { recoverable: [], notices: [] };
|
|
86
|
+
let target = null;
|
|
87
|
+
let noticeSignature = "";
|
|
88
|
+
|
|
89
|
+
const who = (tokens) => {
|
|
90
|
+
if (tokens.some((token) => own.has(token))) return tokens.length > 1 ? "You and others" : "You";
|
|
91
|
+
return tokens.some((token) => token !== null) ? "Another editor" : "An earlier edit";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const close = () => {
|
|
95
|
+
panel.hidden = true;
|
|
96
|
+
target = null;
|
|
97
|
+
button.setAttribute("aria-expanded", "false");
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const restore = (record) => {
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = binding.restore(record.source, record.revision);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("[clay] Restore failed:", error);
|
|
106
|
+
toast("This change could not be restored.", "error");
|
|
107
|
+
return { status: "failed" };
|
|
108
|
+
}
|
|
109
|
+
if (result.status === "restored" || result.status === "resolved") onRestored(result.id);
|
|
110
|
+
else if (result.status === "stale") onStale(result);
|
|
111
|
+
return result;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const draw = () => {
|
|
115
|
+
panel.replaceChildren();
|
|
116
|
+
const head = el(doc, "div", "page-recover-head");
|
|
117
|
+
head.append(doc.createTextNode("Recover changes"));
|
|
118
|
+
const done = el(doc, "button", "", { type: "button" });
|
|
119
|
+
done.textContent = "Close";
|
|
120
|
+
done.addEventListener("click", () => { close(); button.focus(); });
|
|
121
|
+
head.append(done);
|
|
122
|
+
const note = el(doc, "p", "page-recover-note");
|
|
123
|
+
note.textContent = "Text typed while its block was moved or removed. Restore puts it back as a new block.";
|
|
124
|
+
panel.append(head, note);
|
|
125
|
+
for (const record of snapshot.recoverable) {
|
|
126
|
+
const row = el(doc, "div", "page-recover-row", { "data-source": record.source });
|
|
127
|
+
if (record.source === target) row.classList.add("is-target");
|
|
128
|
+
const meta = el(doc, "div", "page-recover-meta");
|
|
129
|
+
meta.textContent = `${TYPE_LABELS[record.type] ?? "Paragraph"} · ${who(authorsOf(record))}`;
|
|
130
|
+
const action = el(doc, "button", "", { type: "button" });
|
|
131
|
+
action.textContent = "Restore";
|
|
132
|
+
action.addEventListener("click", () => restore(record));
|
|
133
|
+
row.append(meta, previewOf(doc, record), action);
|
|
134
|
+
panel.append(row);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const open = (source = null) => {
|
|
139
|
+
if (!snapshot.recoverable.length) return;
|
|
140
|
+
target = source;
|
|
141
|
+
panel.hidden = false;
|
|
142
|
+
button.setAttribute("aria-expanded", "true");
|
|
143
|
+
draw();
|
|
144
|
+
const rows = [...panel.querySelectorAll(".page-recover-row")];
|
|
145
|
+
const focus = (rows.find((row) => row.dataset.source === source) ?? rows[0])?.querySelector("button");
|
|
146
|
+
focus?.focus();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const showNotices = (notices) => {
|
|
150
|
+
const signature = notices.map((notice) => `${notice.source}\u0000${notice.revision}`).join("\n");
|
|
151
|
+
if (signature === noticeSignature) return;
|
|
152
|
+
noticeSignature = signature;
|
|
153
|
+
if (!notices.length) { dismissPersistent(NOTICE_TEXT); return; }
|
|
154
|
+
toastPersistent(NOTICE_TEXT, "warning", {
|
|
155
|
+
actions: [
|
|
156
|
+
{ label: "Review", onClick: () => open(notices[0].source) },
|
|
157
|
+
{ label: "Dismiss", onClick: () => { for (const notice of notices) binding.dismiss(notice.source, notice.revision); } },
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const update = (next) => {
|
|
163
|
+
snapshot = next;
|
|
164
|
+
const count = next.recoverable.length;
|
|
165
|
+
button.hidden = count === 0;
|
|
166
|
+
button.textContent = `Recover changes (${count})`;
|
|
167
|
+
if (!count) close();
|
|
168
|
+
else if (!panel.hidden) draw();
|
|
169
|
+
showNotices(next.notices);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
button.addEventListener("click", () => (panel.hidden ? open() : close()));
|
|
173
|
+
panel.addEventListener("keydown", (event) => {
|
|
174
|
+
if (event.key !== "Escape") return;
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
close();
|
|
177
|
+
button.focus();
|
|
178
|
+
});
|
|
179
|
+
const off = binding.subscribeRecovery(update);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
open,
|
|
183
|
+
close,
|
|
184
|
+
restore,
|
|
185
|
+
get count() { return snapshot.recoverable.length; },
|
|
186
|
+
destroy() {
|
|
187
|
+
off();
|
|
188
|
+
dismissPersistent(NOTICE_TEXT);
|
|
189
|
+
button.remove();
|
|
190
|
+
panel.remove();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const ATOM = "\uFFFC";
|
|
2
|
+
const BREAK = '@{"type":"hard_break"}';
|
|
3
|
+
|
|
4
|
+
export function cellText(value) {
|
|
5
|
+
if (typeof value === "string") return value;
|
|
6
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
7
|
+
if (value === null || typeof value !== "object" || typeof value.c !== "string") return "";
|
|
8
|
+
const keys = [];
|
|
9
|
+
for (const run of Array.isArray(value.m) ? value.m : []) {
|
|
10
|
+
if (!Array.isArray(run)) keys.push(run);
|
|
11
|
+
else for (let i = 0; i < run[0] && keys.length < value.c.length; i++) keys.push(run[1]);
|
|
12
|
+
}
|
|
13
|
+
let out = "";
|
|
14
|
+
for (let i = 0; i < value.c.length; i++) out += value.c[i] === ATOM && keys[i] === BREAK ? "\n" : value.c[i];
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseDecimal } from "./csv.js";
|
|
2
|
+
import { cellText } from "./cell.js";
|
|
3
|
+
|
|
4
|
+
const esc = (value) => String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
5
|
+
const fmt = (value) => Number(value.toFixed(2)).toString();
|
|
6
|
+
|
|
7
|
+
function ticks(min, max, count) {
|
|
8
|
+
if (min === max) { min -= Math.max(Math.abs(min) * .05, 1); max += Math.max(Math.abs(max) * .05, 1); }
|
|
9
|
+
const target = (max - min) / (count - 1);
|
|
10
|
+
const power = 10 ** Math.floor(Math.log10(target));
|
|
11
|
+
const steps = [1, 2, 5, 10].map((n) => n * power);
|
|
12
|
+
let step = steps.find((n) => Math.floor(max / n) - Math.ceil(min / n) + 1 <= count) ?? steps.at(-1);
|
|
13
|
+
while (Math.floor(max / step) - Math.ceil(min / step) + 1 > count) step *= 2;
|
|
14
|
+
const out = [];
|
|
15
|
+
for (let n = Math.ceil(min / step); n <= Math.floor(max / step); n++) out.push(Number((n * step).toPrecision(12)));
|
|
16
|
+
return out.length ? out : [Number(min.toFixed(2)), Number(max.toFixed(2))];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function chartModel(chart, table) {
|
|
20
|
+
if (!table) return { state: "missing", reason: "table" };
|
|
21
|
+
const cols = table.cols ?? [];
|
|
22
|
+
const rows = table.rows ?? [];
|
|
23
|
+
const x = cols.find((col) => col.id === chart.x);
|
|
24
|
+
if (!x || !chart.series?.length || chart.series.some((id) => !cols.some((col) => col.id === id))) return { state: "missing", reason: "column" };
|
|
25
|
+
const series = chart.series.map((id, index) => {
|
|
26
|
+
const col = cols.find((item) => item.id === id);
|
|
27
|
+
const points = rows.map((row, rowIndex) => ({
|
|
28
|
+
id: `${chart.id}:${row.id}:${id}`, rowId: row.id, colId: id, rowIndex,
|
|
29
|
+
xLabel: cellText(row.cells?.[chart.x]), value: parseDecimal(cellText(row.cells?.[id])),
|
|
30
|
+
highlight: row.id === chart.highlight,
|
|
31
|
+
}));
|
|
32
|
+
return { id, name: col.name, color: `var(--chart-${index % 6 + 1})`, points };
|
|
33
|
+
});
|
|
34
|
+
const points = series.flatMap((item) => item.points);
|
|
35
|
+
const values = points.map((point) => point.value).filter((value) => value !== null);
|
|
36
|
+
let min = values.length ? values.reduce((current, value) => Math.min(current, value), Infinity) : 0;
|
|
37
|
+
let max = values.length ? values.reduce((current, value) => Math.max(current, value), -Infinity) : 1;
|
|
38
|
+
if (chart.type === "bar") { min = Math.min(min, 0); max = Math.max(max, 0); }
|
|
39
|
+
else { const pad = (max - min || Math.max(Math.abs(max), 1)) * .05; min -= pad; max += pad; }
|
|
40
|
+
if (min === max) max = min + 1;
|
|
41
|
+
return {
|
|
42
|
+
state: "ok", type: chart.type, title: chart.title ?? "", series, points,
|
|
43
|
+
xLabels: rows.map((row) => cellText(row.cells?.[chart.x])),
|
|
44
|
+
yTicks: ticks(min, max, 6), narrowTicks: ticks(min, max, 3), domain: [min, max],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderChartSvg(model, { width = 640, height = 320, narrow = false } = {}) {
|
|
49
|
+
if (model.state === "missing") return `<svg xmlns="http://www.w3.org/2000/svg" role="img" width="${width}" height="80" viewBox="0 0 ${width} 80"><title>Chart source missing</title><text x="12" y="44">Chart source missing</text></svg>`;
|
|
50
|
+
const margin = { left: 52, right: 72, top: 28, bottom: 48 };
|
|
51
|
+
const plotW = Math.max(1, width - margin.left - margin.right);
|
|
52
|
+
const plotH = Math.max(1, height - margin.top - margin.bottom);
|
|
53
|
+
const [min, max] = model.domain;
|
|
54
|
+
const y = (value) => margin.top + (max - value) / (max - min) * plotH;
|
|
55
|
+
const n = model.xLabels.length;
|
|
56
|
+
const x = (index) => margin.left + (n <= 1 ? plotW / 2 : index * plotW / (n - 1));
|
|
57
|
+
const parts = [`<svg xmlns="http://www.w3.org/2000/svg" role="img" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`, `<title>${esc(model.title)}</title>`];
|
|
58
|
+
const caption = model.series.map((item) => {
|
|
59
|
+
const valid = item.points.filter((point) => point.value !== null);
|
|
60
|
+
return `${item.name}: ${valid.length ? `${fmt(valid[0].value)} to ${fmt(valid.at(-1).value)}` : "no values"}`;
|
|
61
|
+
}).join("; ");
|
|
62
|
+
parts.push(`<desc>${esc(caption)}</desc>`);
|
|
63
|
+
for (const tick of narrow ? model.narrowTicks : model.yTicks) {
|
|
64
|
+
const yy = fmt(y(tick));
|
|
65
|
+
parts.push(`<line x1="${margin.left}" y1="${yy}" x2="${fmt(margin.left + plotW)}" y2="${yy}" stroke="currentColor" opacity="0.15"/><text x="${margin.left - 8}" y="${fmt(y(tick) + 4)}" text-anchor="end">${fmt(tick)}</text>`);
|
|
66
|
+
}
|
|
67
|
+
if (model.type === "bar") {
|
|
68
|
+
const groupW = plotW / Math.max(n, 1);
|
|
69
|
+
const barW = Math.max(1, Math.min(32, groupW * .7 / model.series.length));
|
|
70
|
+
const base = y(0);
|
|
71
|
+
model.series.forEach((item, seriesIndex) => {
|
|
72
|
+
item.points.forEach((point) => {
|
|
73
|
+
if (point.value === null) return;
|
|
74
|
+
const xx = margin.left + (point.rowIndex + .5) * groupW - model.series.length * barW / 2 + seriesIndex * barW;
|
|
75
|
+
const yy = y(point.value);
|
|
76
|
+
parts.push(`<rect data-point="${esc(point.id)}"${point.highlight ? ' data-highlight="true"' : ""} x="${fmt(xx)}" y="${fmt(Math.min(yy, base))}" width="${fmt(barW)}" height="${fmt(Math.abs(base - yy))}" fill="${item.color}"/>`);
|
|
77
|
+
});
|
|
78
|
+
const last = [...item.points].reverse().find((point) => point.value !== null);
|
|
79
|
+
if (last) {
|
|
80
|
+
const xx = margin.left + (last.rowIndex + .5) * groupW - model.series.length * barW / 2 + seriesIndex * barW;
|
|
81
|
+
parts.push(`<text x="${fmt(xx + barW / 2)}" y="${fmt(Math.min(y(last.value), base) - 7)}" text-anchor="middle">${esc(item.name)}</text>`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
model.series.forEach((item) => {
|
|
86
|
+
let segment = [];
|
|
87
|
+
const flush = () => { if (segment.length > 1) parts.push(`<polyline points="${segment.join(" ")}" fill="none" stroke="${item.color}" stroke-width="2"/>`); segment = []; };
|
|
88
|
+
item.points.forEach((point) => {
|
|
89
|
+
if (point.value === null) { flush(); return; }
|
|
90
|
+
segment.push(`${fmt(x(point.rowIndex))},${fmt(y(point.value))}`);
|
|
91
|
+
parts.push(`<circle data-point="${esc(point.id)}"${point.highlight ? ' data-highlight="true"' : ""} cx="${fmt(x(point.rowIndex))}" cy="${fmt(y(point.value))}" r="3" fill="${item.color}"/>`);
|
|
92
|
+
});
|
|
93
|
+
flush();
|
|
94
|
+
const last = [...item.points].reverse().find((point) => point.value !== null);
|
|
95
|
+
if (last) parts.push(`<text x="${fmt(x(last.rowIndex) + 7)}" y="${fmt(y(last.value) - 5)}">${esc(item.name)}</text>`);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
model.xLabels.forEach((label, i) => {
|
|
99
|
+
const xx = model.type === "bar" ? margin.left + (i + .5) * plotW / Math.max(n, 1) : x(i);
|
|
100
|
+
parts.push(`<text x="${fmt(xx)}" y="${fmt(height - 14)}" text-anchor="middle">${esc(label)}</text>`);
|
|
101
|
+
});
|
|
102
|
+
parts.push("</svg>");
|
|
103
|
+
return parts.join("");
|
|
104
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { keysBetween } from "../model.js";
|
|
2
|
+
|
|
3
|
+
export class CsvError extends Error {
|
|
4
|
+
constructor(code, line) {
|
|
5
|
+
super(`CSV ${code} at line ${line}`);
|
|
6
|
+
this.name = "CsvError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.line = line;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function detectedDelimiter(text) {
|
|
13
|
+
const counts = new Map([[",", 0], [";", 0], ["\t", 0]]);
|
|
14
|
+
let quoted = false;
|
|
15
|
+
for (let i = 0; i < text.length; i++) {
|
|
16
|
+
const ch = text[i];
|
|
17
|
+
if (ch === '"') {
|
|
18
|
+
if (quoted && text[i + 1] === '"') i++;
|
|
19
|
+
else quoted = !quoted;
|
|
20
|
+
} else if (!quoted && (ch === "\r" || ch === "\n")) break;
|
|
21
|
+
else if (!quoted && counts.has(ch)) counts.set(ch, counts.get(ch) + 1);
|
|
22
|
+
}
|
|
23
|
+
return [...counts].sort((a, b) => b[1] - a[1])[0][0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseCsv(text, { delimiter } = {}) {
|
|
27
|
+
text = String(text).replace(/^\uFEFF/, "");
|
|
28
|
+
delimiter ??= detectedDelimiter(text);
|
|
29
|
+
if (typeof delimiter !== "string" || delimiter.length !== 1 || /["\r\n]/.test(delimiter)) throw new TypeError("CSV delimiter must be one non-newline character");
|
|
30
|
+
const records = [];
|
|
31
|
+
let row = [], field = "", quoted = false, line = 1, startLine = 1;
|
|
32
|
+
for (let i = 0; i < text.length; i++) {
|
|
33
|
+
const ch = text[i];
|
|
34
|
+
if (quoted) {
|
|
35
|
+
if (ch === '"') {
|
|
36
|
+
if (text[i + 1] === '"') { field += '"'; i++; }
|
|
37
|
+
else quoted = false;
|
|
38
|
+
} else if (ch === "\r" || ch === "\n") {
|
|
39
|
+
field += ch;
|
|
40
|
+
if (ch === "\r" && text[i + 1] === "\n") field += text[++i];
|
|
41
|
+
line++;
|
|
42
|
+
} else field += ch;
|
|
43
|
+
} else if (ch === '"' && field === "") {
|
|
44
|
+
quoted = true;
|
|
45
|
+
startLine = line;
|
|
46
|
+
} else if (ch === delimiter) {
|
|
47
|
+
row.push(field);
|
|
48
|
+
field = "";
|
|
49
|
+
} else if (ch === "\r" || ch === "\n") {
|
|
50
|
+
row.push(field);
|
|
51
|
+
records.push(row);
|
|
52
|
+
row = [];
|
|
53
|
+
field = "";
|
|
54
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
55
|
+
line++;
|
|
56
|
+
} else field += ch;
|
|
57
|
+
}
|
|
58
|
+
if (quoted) throw new CsvError("unterminated-quote", startLine);
|
|
59
|
+
if (row.length || field !== "" || (text && !/[\r\n]$/.test(text))) records.push([...row, field]);
|
|
60
|
+
if (!records.length) return { header: [], rows: [] };
|
|
61
|
+
const [header, ...rows] = records;
|
|
62
|
+
const width = Math.max(header.length, ...rows.map((r) => r.length));
|
|
63
|
+
header.length = width;
|
|
64
|
+
for (let i = 0; i < width; i++) header[i] ??= "";
|
|
65
|
+
for (const r of rows) for (let i = r.length; i < width; i++) r.push("");
|
|
66
|
+
return { header, rows };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseDecimal(value) {
|
|
70
|
+
if (typeof value !== "string" || !/^[+-]?(?:0|[1-9]\d*)(?:[.,]\d+)?$/.test(value)) return null;
|
|
71
|
+
const number = Number(value.replace(",", "."));
|
|
72
|
+
return Number.isFinite(number) ? number : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function inferKinds(header, rows) {
|
|
76
|
+
return header.map((_, col) => {
|
|
77
|
+
const values = rows.map((row) => row[col] ?? "").filter((value) => value.trim() !== "");
|
|
78
|
+
if (!values.length) return "text";
|
|
79
|
+
const separators = new Set(values.map((value) => value.includes(",") ? "," : value.includes(".") ? "." : null).filter(Boolean));
|
|
80
|
+
if (separators.size <= 1 && values.every((value) => parseDecimal(value) !== null)) return "number";
|
|
81
|
+
if (values.every((value) => {
|
|
82
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
83
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
84
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
85
|
+
})) return "date";
|
|
86
|
+
return "text";
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const COLUMN_KINDS = Object.freeze(["text", "number", "date"]);
|
|
91
|
+
|
|
92
|
+
export function effectiveKind(stored, values) {
|
|
93
|
+
return COLUMN_KINDS.includes(stored) ? stored : inferKinds([""], values.map((value) => [value]))[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function tableFromCsv({ header, rows: inputRows }, { maxBytes = Infinity, newId } = {}) {
|
|
97
|
+
if (typeof newId !== "function") throw new TypeError("tableFromCsv needs newId");
|
|
98
|
+
const kinds = inferKinds(header, inputRows);
|
|
99
|
+
const cols = {};
|
|
100
|
+
const ids = header.map(() => newId());
|
|
101
|
+
const colPositions = keysBetween(null, null, header.length);
|
|
102
|
+
header.forEach((name, i) => { cols[ids[i]] = { pos: colPositions[i], name, kind: kinds[i] }; });
|
|
103
|
+
const rows = {};
|
|
104
|
+
const positions = keysBetween(null, null, inputRows.length);
|
|
105
|
+
const encoder = new TextEncoder();
|
|
106
|
+
let size = encoder.encode(JSON.stringify({ cols, rows })).length;
|
|
107
|
+
let kept = 0;
|
|
108
|
+
for (let i = 0; i < inputRows.length; i++) {
|
|
109
|
+
const id = newId();
|
|
110
|
+
const row = { pos: positions[i], cells: Object.fromEntries(ids.map((col, j) => [col, inputRows[i][j] ?? ""])) };
|
|
111
|
+
const increment = encoder.encode(`${kept ? "," : ""}${JSON.stringify(id)}:${JSON.stringify(row)}`).length;
|
|
112
|
+
if (size + increment > maxBytes) break;
|
|
113
|
+
rows[id] = row;
|
|
114
|
+
size += increment;
|
|
115
|
+
kept++;
|
|
116
|
+
}
|
|
117
|
+
return { cols, rows, truncated: kept === inputRows.length ? null : { kept, total: inputRows.length } };
|
|
118
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { cloneJson } from "../../live/ops.js";
|
|
2
|
+
import { validateArtifact, validateState } from "./tool-runtime.js";
|
|
3
|
+
|
|
4
|
+
const CONTEXT_LIMIT = 16384;
|
|
5
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
6
|
+
|
|
7
|
+
/** @typedef {{protocol: 'page-tool/1', requestId: string, prompt: string, context: {title: string, outline: unknown[], tables: unknown[]}, target: null | {toolId: string, rev: string}}} ToolRequest */
|
|
8
|
+
/** @typedef {{requestId: string, artifact: object, state: unknown}} ToolResult */
|
|
9
|
+
/** @typedef {{requestId: string, code: string, message: string}} ToolFailure */
|
|
10
|
+
|
|
11
|
+
export class ToolRequestError extends Error {
|
|
12
|
+
constructor(code, message = code) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ToolRequestError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trimmedContext(context) {
|
|
20
|
+
const copy = cloneJson(context);
|
|
21
|
+
if (!Array.isArray(copy.tables)) copy.tables = [];
|
|
22
|
+
if (!Array.isArray(copy.outline)) copy.outline = [];
|
|
23
|
+
while (bytes(copy) > CONTEXT_LIMIT) {
|
|
24
|
+
const table = [...copy.tables].reverse().find((item) => Array.isArray(item.rows) && item.rows.length);
|
|
25
|
+
if (table) table.rows.pop();
|
|
26
|
+
else if (copy.outline.length) copy.outline.pop();
|
|
27
|
+
else break;
|
|
28
|
+
}
|
|
29
|
+
if (bytes(copy) > CONTEXT_LIMIT) throw new ToolRequestError("context-too-large");
|
|
30
|
+
return copy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function capabilityAdvertised(meta) {
|
|
34
|
+
return Array.isArray(meta?.extensions) && meta.extensions.includes("page-tool/1");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createToolClient({ transport, newId }) {
|
|
38
|
+
let active = null;
|
|
39
|
+
const discard = (candidate) => {
|
|
40
|
+
if (!candidate || candidate.discarded || candidate.done) return;
|
|
41
|
+
candidate.discarded = true;
|
|
42
|
+
if (active === candidate) active = null;
|
|
43
|
+
transport.cancel(candidate.requestId);
|
|
44
|
+
candidate.resolve({ requestId: candidate.requestId, code: "discarded", message: "Candidate discarded" });
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
request(prompt, context, target = null) {
|
|
48
|
+
const trimmed = trimmedContext(context);
|
|
49
|
+
if (active) discard(active);
|
|
50
|
+
const requestId = newId();
|
|
51
|
+
const candidate = { requestId, discarded: false, done: false };
|
|
52
|
+
candidate.promise = new Promise((resolve) => { candidate.resolve = resolve; });
|
|
53
|
+
active = candidate;
|
|
54
|
+
const request = { protocol: "page-tool/1", requestId, prompt, context: trimmed, target };
|
|
55
|
+
candidate.request = request;
|
|
56
|
+
const finish = (reply) => {
|
|
57
|
+
if (candidate.discarded || candidate.done || reply?.requestId !== requestId) return;
|
|
58
|
+
candidate.done = true;
|
|
59
|
+
if (active === candidate) active = null;
|
|
60
|
+
if (reply?.artifact !== undefined) {
|
|
61
|
+
const artifact = validateArtifact(reply.artifact);
|
|
62
|
+
const state = validateState(reply.state);
|
|
63
|
+
const invalid = !artifact.ok ? artifact : !state.ok ? state : null;
|
|
64
|
+
candidate.resolve(invalid ? { requestId, code: invalid.code, message: invalid.code } : reply);
|
|
65
|
+
} else candidate.resolve(typeof reply?.code === "string" ? reply : { requestId, code: "invalid-result", message: "invalid-result" });
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
Promise.resolve(transport.request(request)).then(finish, (error) => finish({ requestId, code: "transport-error", message: String(error?.message ?? error) }));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
finish({ requestId, code: "transport-error", message: String(error?.message ?? error) });
|
|
71
|
+
}
|
|
72
|
+
return candidate;
|
|
73
|
+
},
|
|
74
|
+
discard,
|
|
75
|
+
async wait(candidate, { signal } = {}) {
|
|
76
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
77
|
+
let abort;
|
|
78
|
+
const outcome = await (signal ? Promise.race([
|
|
79
|
+
candidate.promise,
|
|
80
|
+
new Promise((_, reject) => { abort = () => reject(new DOMException("Aborted", "AbortError")); signal.addEventListener("abort", abort, { once: true }); }),
|
|
81
|
+
]).finally(() => signal.removeEventListener("abort", abort)) : candidate.promise);
|
|
82
|
+
if (outcome?.code) throw new ToolRequestError(outcome.code, outcome.message);
|
|
83
|
+
return outcome;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|