wendang 0.0.1 → 0.1.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/LICENSE +3 -0
- package/NOTICE.md +7 -0
- package/README.md +101 -2
- package/dist/cjs/document.js +19 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/mindmap.js +22 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/schema.js +11 -0
- package/dist/cjs/shared/anchor.js +201 -0
- package/dist/cjs/shared/callout.js +64 -0
- package/dist/cjs/shared/code-block.js +33 -0
- package/dist/cjs/shared/code-languages.js +51 -0
- package/dist/cjs/shared/doc-compatibility.js +23 -0
- package/dist/cjs/shared/doc-text.js +52 -0
- package/dist/cjs/shared/document-colors.js +61 -0
- package/dist/cjs/shared/inline-comments.js +179 -0
- package/dist/cjs/shared/mind-map-folding.js +14 -0
- package/dist/cjs/shared/mind-map-phase2.js +133 -0
- package/dist/cjs/shared/mind-map.js +445 -0
- package/dist/cjs/shared/mind-topic-text.js +57 -0
- package/dist/cjs/shared/node-catalog.js +62 -0
- package/dist/cjs/shared/presence.js +141 -0
- package/dist/cjs/shared/safe-href.js +36 -0
- package/dist/cjs/shared/table-cell-format.js +18 -0
- package/dist/cjs/shared/table-layout.js +28 -0
- package/dist/cjs/shared/types.js +2 -0
- package/dist/cjs/shared/video-embed.js +102 -0
- package/dist/cjs/shared/workbook.js +152 -0
- package/dist/cjs/spreadsheet.js +17 -0
- package/dist/cjs/types.js +17 -0
- package/dist/cjs/validation/callout.js +17 -0
- package/dist/cjs/validation/doc-node-whitelist.js +71 -0
- package/dist/cjs/validation/error.js +17 -0
- package/dist/cjs/validation/mind-map.js +129 -0
- package/dist/cjs/validation/schema-version.js +5 -0
- package/dist/cjs/validation/schema.js +376 -0
- package/dist/cjs/validation/video-embed.js +17 -0
- package/dist/cjs/validation/workbook.js +17 -0
- package/dist/esm/document.js +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/mindmap.js +4 -0
- package/dist/esm/schema.js +3 -0
- package/dist/esm/shared/anchor.js +192 -0
- package/dist/esm/shared/callout.js +56 -0
- package/dist/esm/shared/code-block.js +25 -0
- package/dist/esm/shared/code-languages.js +46 -0
- package/dist/esm/shared/doc-compatibility.js +20 -0
- package/dist/esm/shared/doc-text.js +48 -0
- package/dist/esm/shared/document-colors.js +56 -0
- package/dist/esm/shared/inline-comments.js +173 -0
- package/dist/esm/shared/mind-map-folding.js +10 -0
- package/dist/esm/shared/mind-map-phase2.js +121 -0
- package/dist/esm/shared/mind-map.js +427 -0
- package/dist/esm/shared/mind-topic-text.js +49 -0
- package/dist/esm/shared/node-catalog.js +57 -0
- package/dist/esm/shared/presence.js +130 -0
- package/dist/esm/shared/safe-href.js +32 -0
- package/dist/esm/shared/table-cell-format.js +14 -0
- package/dist/esm/shared/table-layout.js +23 -0
- package/dist/esm/shared/types.js +1 -0
- package/dist/esm/shared/video-embed.js +96 -0
- package/dist/esm/shared/workbook.js +144 -0
- package/dist/esm/spreadsheet.js +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/validation/callout.js +1 -0
- package/dist/esm/validation/doc-node-whitelist.js +68 -0
- package/dist/esm/validation/error.js +13 -0
- package/dist/esm/validation/mind-map.js +125 -0
- package/dist/esm/validation/schema-version.js +2 -0
- package/dist/esm/validation/schema.js +372 -0
- package/dist/esm/validation/video-embed.js +1 -0
- package/dist/esm/validation/workbook.js +1 -0
- package/dist/types/document.d.ts +4 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mindmap.d.ts +4 -0
- package/dist/types/schema.d.ts +4 -0
- package/dist/types/shared/anchor.d.ts +87 -0
- package/dist/types/shared/callout.d.ts +27 -0
- package/dist/types/shared/code-block.d.ts +26 -0
- package/dist/types/shared/code-languages.d.ts +26 -0
- package/dist/types/shared/doc-compatibility.d.ts +1 -0
- package/dist/types/shared/doc-text.d.ts +21 -0
- package/dist/types/shared/document-colors.d.ts +106 -0
- package/dist/types/shared/inline-comments.d.ts +71 -0
- package/dist/types/shared/mind-map-folding.d.ts +5 -0
- package/dist/types/shared/mind-map-phase2.d.ts +19 -0
- package/dist/types/shared/mind-map.d.ts +134 -0
- package/dist/types/shared/mind-topic-text.d.ts +18 -0
- package/dist/types/shared/node-catalog.d.ts +30 -0
- package/dist/types/shared/presence.d.ts +69 -0
- package/dist/types/shared/safe-href.d.ts +3 -0
- package/dist/types/shared/table-cell-format.d.ts +24 -0
- package/dist/types/shared/table-layout.d.ts +7 -0
- package/dist/types/shared/types.d.ts +44 -0
- package/dist/types/shared/video-embed.d.ts +11 -0
- package/dist/types/shared/workbook.d.ts +56 -0
- package/dist/types/spreadsheet.d.ts +1 -0
- package/dist/types/types.d.ts +3 -0
- package/dist/types/validation/callout.d.ts +1 -0
- package/dist/types/validation/doc-node-whitelist.d.ts +21 -0
- package/dist/types/validation/error.d.ts +7 -0
- package/dist/types/validation/mind-map.d.ts +22 -0
- package/dist/types/validation/schema-version.d.ts +2 -0
- package/dist/types/validation/schema.d.ts +49 -0
- package/dist/types/validation/video-embed.d.ts +1 -0
- package/dist/types/validation/workbook.d.ts +1 -0
- package/package.json +87 -5
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateMindMap = validateMindMap;
|
|
4
|
+
exports.mindMapOutline = mindMapOutline;
|
|
5
|
+
const idOk = (v) => typeof v === 'string' && /^[\w-]{1,80}$/.test(v);
|
|
6
|
+
function validateMindMap(node) {
|
|
7
|
+
const a = node.attrs;
|
|
8
|
+
if (!a || ![1, 2, 3].includes(Number(a.formatVersion)) || !idOk(a.id) || !idOk(a.rootId) ||
|
|
9
|
+
!['right', 'left', 'both', 'down'].includes(String(a.layout)) ||
|
|
10
|
+
!['blue', 'teal', 'violet', 'amber'].includes(String(a.theme)) ||
|
|
11
|
+
typeof a.height !== 'number' || !Number.isFinite(a.height) || a.height < 240 || a.height > 800) {
|
|
12
|
+
throw new Error('思维导图属性或格式版本无效');
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(node.content) || node.content.length < 1 || node.content.filter(t => t?.type === 'mindMapTopic').length > 500 || node.content.length > 1500) {
|
|
15
|
+
throw new Error('思维导图需要 1~500 个主题');
|
|
16
|
+
}
|
|
17
|
+
const ids = new Set();
|
|
18
|
+
for (const t of node.content.filter(t => t?.type !== 'mindMapRelation')) {
|
|
19
|
+
const p = t?.attrs;
|
|
20
|
+
if (!t || t.type !== 'mindMapTopic' || !p || !idOk(p.id) || ids.has(p.id) ||
|
|
21
|
+
typeof p.parentId !== 'string' || (p.parentId !== '' && !idOk(p.parentId)) ||
|
|
22
|
+
typeof p.order !== 'number' || !Number.isFinite(p.order) || Math.abs(p.order) > 1e12 ||
|
|
23
|
+
(t.content !== undefined && !Array.isArray(t.content)))
|
|
24
|
+
throw new Error('思维导图主题结构无效');
|
|
25
|
+
ids.add(p.id);
|
|
26
|
+
for (const [key, values] of Object.entries({ shape: ['round', 'pill', 'text'], align: ['left', 'center', 'right'] })) {
|
|
27
|
+
if (p[key] !== undefined && p[key] !== null && !values.includes(String(p[key])))
|
|
28
|
+
throw new Error('导图主题样式无效');
|
|
29
|
+
}
|
|
30
|
+
for (const key of ['color', 'background'])
|
|
31
|
+
if (p[key] && !['#18243a', '#ffffff', '#3864ef', '#087f8c', '#7950c7', '#a66b08', '#d94848', '#f3f5fb', '#dce7ff', '#d3f4e9', '#ece0ff', '#fff0c2', '#ffe1e1', '#233044'].includes(String(p[key])))
|
|
32
|
+
throw new Error('导图颜色无效');
|
|
33
|
+
if (p.fontSize !== undefined && p.fontSize !== null && ![12, 14, 16, 18, 20].includes(Number(p.fontSize)))
|
|
34
|
+
throw new Error('导图字号无效');
|
|
35
|
+
if (p.icon && !['💡', '🎯', '📌', '🚩', '⭐', '✅', '⚠️', '💬', '📅', '🔎'].includes(String(p.icon)))
|
|
36
|
+
throw new Error('导图图标无效');
|
|
37
|
+
if (p.done !== undefined && p.done !== null && typeof p.done !== 'boolean')
|
|
38
|
+
throw new Error('导图状态无效');
|
|
39
|
+
if (p.side !== undefined && p.side !== null && p.side !== -1 && p.side !== 1)
|
|
40
|
+
throw new Error('导图分支方向无效');
|
|
41
|
+
if (p.attachmentId && (typeof p.attachmentId !== 'string' || !/^[0-9a-f-]{36}$/i.test(p.attachmentId)))
|
|
42
|
+
throw new Error('导图图片引用无效');
|
|
43
|
+
let length = 0;
|
|
44
|
+
const fields = new Set();
|
|
45
|
+
let fieldsStarted = false, noteStarted = false;
|
|
46
|
+
for (const c of t.content ?? []) {
|
|
47
|
+
if (c && ['mindMapDescription', 'mindMapNote'].includes(c.type ?? '')) {
|
|
48
|
+
if (c.type === 'mindMapDescription' && noteStarted)
|
|
49
|
+
throw new Error('主题描述必须位于备注之前');
|
|
50
|
+
fieldsStarted = true;
|
|
51
|
+
if (c.type === 'mindMapNote')
|
|
52
|
+
noteStarted = true;
|
|
53
|
+
if (fields.has(c.type) || (c.content !== undefined && !Array.isArray(c.content)) || c.content?.some(n => n?.type !== 'text' || typeof n.text !== 'string' || n.content !== undefined) || (c.content ?? []).reduce((n, x) => n + (x.text?.length ?? 0), 0) > 4000)
|
|
54
|
+
throw new Error('导图描述或备注无效');
|
|
55
|
+
fields.add(c.type);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!c || c.type !== 'text' || typeof c.text !== 'string' || c.content !== undefined) {
|
|
59
|
+
throw new Error('思维导图主题只支持纯文本');
|
|
60
|
+
}
|
|
61
|
+
if (fieldsStarted)
|
|
62
|
+
throw new Error('主题标题必须位于描述和备注之前');
|
|
63
|
+
length += c.text.length;
|
|
64
|
+
}
|
|
65
|
+
if (length > 2000)
|
|
66
|
+
throw new Error('单个主题文字不得超过 2000 字符');
|
|
67
|
+
}
|
|
68
|
+
if (!ids.has(a.rootId))
|
|
69
|
+
throw new Error('思维导图缺少中心主题');
|
|
70
|
+
if (a.branchStyle && !['curve', 'straight', 'step'].includes(String(a.branchStyle)))
|
|
71
|
+
throw new Error('分支样式无效');
|
|
72
|
+
const relationIds = new Set();
|
|
73
|
+
for (const r of node.content.filter(t => t?.type === 'mindMapRelation')) {
|
|
74
|
+
const p = r.attrs;
|
|
75
|
+
if (!p || !idOk(p.id) || ids.has(p.id) || relationIds.has(p.id) || !idOk(p.source) || !idOk(p.target) || p.source === p.target ||
|
|
76
|
+
!['related', 'depends', 'reference'].includes(String(p.kind)) || typeof p.label !== 'string' || p.label.length > 200 || r.content?.length)
|
|
77
|
+
throw new Error('导图关系线无效');
|
|
78
|
+
relationIds.add(p.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Same deterministic tree projection as the reader; orphan/cycle conflicts never discard text. */
|
|
82
|
+
function mindMapOutline(node) {
|
|
83
|
+
const rootId = String(node.attrs?.rootId ?? '');
|
|
84
|
+
if (!idOk(rootId) || !Array.isArray(node.content) || node.content.filter(t => t?.type === 'mindMapTopic').length > 500 || node.content.length > 1500)
|
|
85
|
+
return [];
|
|
86
|
+
const topics = (node.content ?? []).filter(t => t.type === 'mindMapTopic').map(t => ({
|
|
87
|
+
id: String(t.attrs?.id ?? ''), parentId: String(t.attrs?.parentId ?? ''),
|
|
88
|
+
order: Number(t.attrs?.order) || 0, text: (t.content ?? []).filter(c => c.type === 'text').map(c => c.text ?? '').join(''),
|
|
89
|
+
...Object.fromEntries((t.content ?? []).filter(c => c.type === 'mindMapDescription' || c.type === 'mindMapNote')
|
|
90
|
+
.map(c => [c.type === 'mindMapDescription' ? 'description' : 'note', (c.content ?? []).map(n => n.text ?? '').join('')])),
|
|
91
|
+
...(t.attrs?.attachmentId ? { attachmentId: String(t.attrs.attachmentId) } : {}),
|
|
92
|
+
}));
|
|
93
|
+
if (!topics.some(t => t.id === rootId))
|
|
94
|
+
return [];
|
|
95
|
+
const byId = new Map(topics.map(t => [t.id, t]));
|
|
96
|
+
for (const t of topics) {
|
|
97
|
+
if (t.id === rootId)
|
|
98
|
+
t.parentId = '';
|
|
99
|
+
else if (!byId.has(t.parentId) || t.parentId === t.id)
|
|
100
|
+
t.parentId = rootId;
|
|
101
|
+
}
|
|
102
|
+
for (const start of [...topics].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
103
|
+
const path = [], seen = new Set();
|
|
104
|
+
let current = start;
|
|
105
|
+
while (current && current.id !== rootId) {
|
|
106
|
+
if (seen.has(current.id)) {
|
|
107
|
+
byId.get(path.slice(path.indexOf(current.id)).sort()[0]).parentId = rootId;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
path.push(current.id);
|
|
111
|
+
seen.add(current.id);
|
|
112
|
+
current = byId.get(current.parentId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
topics.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
116
|
+
const result = [];
|
|
117
|
+
const visited = new Set();
|
|
118
|
+
const visit = (id, depth) => {
|
|
119
|
+
const topic = byId.get(id);
|
|
120
|
+
if (!topic || visited.has(id))
|
|
121
|
+
return;
|
|
122
|
+
visited.add(id);
|
|
123
|
+
result.push({ ...topic, depth });
|
|
124
|
+
for (const child of topics.filter(t => t.parentId === id))
|
|
125
|
+
visit(child.id, depth + 1);
|
|
126
|
+
};
|
|
127
|
+
visit(rootId, 0);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ContentSchemaService = exports.SCHEMA_VERSION = exports.ALLOWED_NODES = void 0;
|
|
4
|
+
const error_js_1 = require("./error.js");
|
|
5
|
+
const ErrorCode = { VALIDATION_FAILED: 'validation.failed' };
|
|
6
|
+
const doc_node_whitelist_js_1 = require("./doc-node-whitelist.js");
|
|
7
|
+
Object.defineProperty(exports, "ALLOWED_NODES", { enumerable: true, get: function () { return doc_node_whitelist_js_1.ALLOWED_NODES; } });
|
|
8
|
+
const video_embed_js_1 = require("./video-embed.js");
|
|
9
|
+
const schema_version_js_1 = require("./schema-version.js");
|
|
10
|
+
const mind_map_js_1 = require("./mind-map.js");
|
|
11
|
+
const callout_js_1 = require("./callout.js");
|
|
12
|
+
const workbook_js_1 = require("./workbook.js");
|
|
13
|
+
/**
|
|
14
|
+
* 文档 schema 校验与派生提取(M0 定稿,见实施计划 4.1)。
|
|
15
|
+
*
|
|
16
|
+
* 职责:
|
|
17
|
+
* 1) 校验客户端提交的 ProseMirror JSON 是否只含白名单节点/标记 → 否则**拒绝写入**,
|
|
18
|
+
* 避免旧客户端遇到未知节点的文档时覆盖成空(对齐主方案第 3 节)。
|
|
19
|
+
* 2) 提取派生数据:正文引用的页面 ID(用于反向链接)、附件 ID。
|
|
20
|
+
*
|
|
21
|
+
* schemaVersion 升级策略:新增节点或旧客户端无法保留的持久属性时提升 SCHEMA_VERSION,并在此登记。
|
|
22
|
+
* 旧版本客户端读到更高版本文档时由前端拒编辑(本服务不影响读取)。
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* 正文格式版本。
|
|
26
|
+
*
|
|
27
|
+
* 变更记录:
|
|
28
|
+
* - 1:M0 初始版本(段落/标题/列表/引用/代码/表格/图片/页面引用/提及/公式)
|
|
29
|
+
* - 2:新增 `fileAttachment`(视频或文件卡片)与 `callout`(高亮块)
|
|
30
|
+
* - 3:新增块级格式属性 `textAlign`/`indent`(段落与标题)与行内样式标记
|
|
31
|
+
* `textStyle`(文字颜色)/`highlight`(背景高亮);同时补上此前遗漏的
|
|
32
|
+
* `paragraph` 属性条目与表格 `colwidth`(列宽)
|
|
33
|
+
* - 4:新增分栏节点 `columns`/`column`(等宽,无属性)
|
|
34
|
+
* - 5:新增平台视频节点 `videoEmbed`
|
|
35
|
+
* - 6:新增结构化思维导图 `mindMap` / `mindMapTopic`
|
|
36
|
+
* - 7:导图描述、备注、关系节点、受限主题样式与图片引用;标题支持行内标记
|
|
37
|
+
* - 8:思维导图分支方向 side,防止旧编辑器重写时丢失左右布局
|
|
38
|
+
* - 9:高亮块图标与独立文字、边框、填充颜色
|
|
39
|
+
* - 10:独立工作簿根格式 workbook(不进入 ProseMirror)
|
|
40
|
+
*
|
|
41
|
+
* 提升它的意义:客户端读到**更高版本**的文档时可以选择拒编辑(而非静默丢内容),
|
|
42
|
+
* 因此只要引入了新的节点类型就必须登记在此,不能只改白名单。
|
|
43
|
+
*/
|
|
44
|
+
exports.SCHEMA_VERSION = schema_version_js_1.CONTENT_SCHEMA_VERSION;
|
|
45
|
+
/** 属性白名单:只保留我们认识且安全的属性,其余剥离(防 XSS 与样式污染)。 */
|
|
46
|
+
const ALLOWED_ATTRS = {
|
|
47
|
+
// 标题:级别 + 块级格式(对齐/缩进)
|
|
48
|
+
heading: new Set(['level', 'textAlign', 'indent']),
|
|
49
|
+
// 段落此前**没有条目** → 任何段落属性都会被丢弃(对齐/缩进加上来时必须补)
|
|
50
|
+
paragraph: new Set(['textAlign', 'indent']),
|
|
51
|
+
// Tiptap / Markdown 已有的标准属性;遗漏会让从 3 开始的列表保存后变回 1。
|
|
52
|
+
orderedList: new Set(['start']),
|
|
53
|
+
// 行内样式标记的颜色值(漏登记 = 颜色丢失,只剩空标记)
|
|
54
|
+
textStyle: new Set(['color']),
|
|
55
|
+
highlight: new Set(['color']),
|
|
56
|
+
// width:等比缩放的显式宽度(四角手柄);align:图片工具条的左/中/右对齐。
|
|
57
|
+
// 两者都必须登记,否则作者调整尺寸/对齐后一经保存就被静默剥离。
|
|
58
|
+
image: new Set(['attachmentId', 'alt', 'width', 'align']),
|
|
59
|
+
fileAttachment: new Set(['attachmentId', 'filename', 'mimeType', 'size']),
|
|
60
|
+
videoEmbed: new Set(['provider', 'videoId', 'page', 'startSeconds']),
|
|
61
|
+
mindMap: new Set(['id', 'rootId', 'formatVersion', 'layout', 'theme', 'height', 'branchStyle']),
|
|
62
|
+
mindMapTopic: new Set(['id', 'parentId', 'order', 'shape', 'color', 'background', 'fontSize', 'align', 'icon', 'done', 'attachmentId', 'side']),
|
|
63
|
+
mindMapRelation: new Set(['id', 'source', 'target', 'kind', 'label']),
|
|
64
|
+
callout: new Set(['tone', 'icon', 'textColor', 'borderColor', 'backgroundColor']),
|
|
65
|
+
codeBlock: new Set(['language']),
|
|
66
|
+
link: new Set(['href', 'target', 'rel']),
|
|
67
|
+
pageLink: new Set(['pageId', 'label']),
|
|
68
|
+
mention: new Set(['userId', 'label']),
|
|
69
|
+
// colwidth:Tiptap 的列宽就是这个属性。此前未登记 → 拖动列宽保存后被
|
|
70
|
+
// 静默剥离(且当时没有任何测试覆盖属性白名单,所以长期未被发现)。
|
|
71
|
+
tableCell: new Set(['colspan', 'rowspan', 'align', 'colwidth', 'backgroundColor']),
|
|
72
|
+
tableHeader: new Set(['colspan', 'rowspan', 'align', 'colwidth', 'backgroundColor']),
|
|
73
|
+
taskItem: new Set(['checked']),
|
|
74
|
+
mathInline: new Set(['latex']),
|
|
75
|
+
mathBlock: new Set(['latex']),
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* 需要强制为**正整数**的表格属性。
|
|
79
|
+
*
|
|
80
|
+
* ## 为什么只登记名字不够(实测的正文整篇消失)
|
|
81
|
+
*
|
|
82
|
+
* 白名单只保证"属性名合法",不保证**类型**合法。历史数据里出现过
|
|
83
|
+
* `colspan: "2"` / `colspan: 0` / `colwidth: "[180,0]"`:
|
|
84
|
+
*
|
|
85
|
+
* - 字符串 colspan 会让 `prosemirror-tables` 的 `findWidth()` 执行
|
|
86
|
+
* `rowWidth += colspan` 时发生**字符串拼接**(`0 + "1"` → `"01"`),
|
|
87
|
+
* `width` 膨胀到 1e39,`computeMap()` 的 `map[i] = 0` 抛
|
|
88
|
+
* `RangeError: Invalid array length`;
|
|
89
|
+
* - `colspan: 0` 会让列宽数组长度与 colspan 不匹配;
|
|
90
|
+
* - 字符串 colwidth 会在 `removeColSpan` 的 `slice().splice()` 处抛错。
|
|
91
|
+
*
|
|
92
|
+
* 三者都挂在 Tiptap 挂载期,表现为**正文区整体渲染失败**(用户看到"标题与目录
|
|
93
|
+
* 还在、正文空白")。因此这里在入库边界上做强类型归一:把能安全转换的转成正确
|
|
94
|
+
* 类型,转换不了的**丢弃该属性**(回退 schema 默认值),而不是原样存进去。
|
|
95
|
+
*
|
|
96
|
+
* 前端另有读取侧兜底(`web/src/shared/table-attrs.ts`),因为历史数据已经存在,
|
|
97
|
+
* 且协同路径不经过本服务的校验。
|
|
98
|
+
*/
|
|
99
|
+
const POSITIVE_INT_ATTRS = new Set(['colspan', 'rowspan']);
|
|
100
|
+
const CELL_BACKGROUND_COLORS = new Set([
|
|
101
|
+
'#f1f3f5', '#fff3bf', '#d3f9d8', '#d0ebff', '#e5dbff', '#ffe3e3',
|
|
102
|
+
]);
|
|
103
|
+
/** 该节点是否是表格单元格(需要强类型化数值属性)。 */
|
|
104
|
+
function isTableCellType(type) {
|
|
105
|
+
return type === 'tableCell' || type === 'tableHeader';
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 把值归一为 `[1, +∞)` 的整数;不合法返回 undefined(表示丢弃该属性)。
|
|
109
|
+
*
|
|
110
|
+
* 不抛错而丢弃的理由:这是**净化**而非校验——粘贴来的内容常带脏属性,
|
|
111
|
+
* 为此拒绝整个保存会让用户无法保存正文,代价远大于丢掉一个单元格属性。
|
|
112
|
+
*/
|
|
113
|
+
function coercePositiveInt(raw) {
|
|
114
|
+
const n = typeof raw === 'number' ? raw : Number.parseInt(String(raw ?? ''), 10);
|
|
115
|
+
if (!Number.isFinite(n))
|
|
116
|
+
return undefined;
|
|
117
|
+
const i = Math.floor(n);
|
|
118
|
+
return i >= 1 ? i : undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 把 colwidth 归一为「正整数数组」或 null;不合法返回 undefined(丢弃)。
|
|
122
|
+
*
|
|
123
|
+
* 注意 0 是**合法分量**(表示"该列自适应"),长度必须与 colspan 对齐,
|
|
124
|
+
* 因此这里保留 0、只把非数字分量归 0。
|
|
125
|
+
*/
|
|
126
|
+
function coerceColwidth(raw) {
|
|
127
|
+
if (raw === null)
|
|
128
|
+
return null;
|
|
129
|
+
let list;
|
|
130
|
+
if (Array.isArray(raw)) {
|
|
131
|
+
list = raw;
|
|
132
|
+
}
|
|
133
|
+
else if (typeof raw === 'string') {
|
|
134
|
+
const text = raw.trim();
|
|
135
|
+
if (!text)
|
|
136
|
+
return null;
|
|
137
|
+
if (text.startsWith('[')) {
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(text);
|
|
140
|
+
list = Array.isArray(parsed) ? parsed : [];
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
list = text.split(',');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (list.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
const nums = list.map((v) => {
|
|
156
|
+
const n = typeof v === 'number' ? v : Number.parseInt(String(v ?? ''), 10);
|
|
157
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
158
|
+
});
|
|
159
|
+
return nums.some((n) => n > 0) ? nums : null;
|
|
160
|
+
}
|
|
161
|
+
const MAX_NODES = 20000; // 防御性上限,避免超大文档拖垮服务
|
|
162
|
+
const MAX_DEPTH = 100;
|
|
163
|
+
class ContentSchemaService {
|
|
164
|
+
validate(raw, schemaVersion) {
|
|
165
|
+
if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 1)
|
|
166
|
+
throw error_js_1.ContentValidationError.validation('内容版本必须是正整数');
|
|
167
|
+
if ((0, workbook_js_1.isWorkbookDocument)(raw)) {
|
|
168
|
+
if (schemaVersion !== 10)
|
|
169
|
+
throw error_js_1.ContentValidationError.validation('工作表需要当前编辑器版本');
|
|
170
|
+
try {
|
|
171
|
+
return { doc: (0, workbook_js_1.validateWorkbook)(raw), schemaVersion,
|
|
172
|
+
linkTargetIds: [], attachmentIds: [], mentionUserIds: [] };
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw error_js_1.ContentValidationError.validation(error instanceof Error ? error.message : '工作表格式无效');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (typeof raw !== 'object' ||
|
|
179
|
+
raw === null ||
|
|
180
|
+
raw.type !== 'doc') {
|
|
181
|
+
throw error_js_1.ContentValidationError.validation('正文格式非法:根节点必须是 doc');
|
|
182
|
+
}
|
|
183
|
+
if (schemaVersion > exports.SCHEMA_VERSION) {
|
|
184
|
+
throw new error_js_1.ContentValidationError(ErrorCode.VALIDATION_FAILED, `正文版本 ${schemaVersion} 高于服务端支持的 ${exports.SCHEMA_VERSION},请升级后再编辑`, 422);
|
|
185
|
+
}
|
|
186
|
+
const linkTargetIds = new Set();
|
|
187
|
+
const attachmentIds = new Set();
|
|
188
|
+
const mentionUserIds = new Set();
|
|
189
|
+
let nodeCount = 0;
|
|
190
|
+
const walk = (node, depth, parentType = '') => {
|
|
191
|
+
if (typeof node !== 'object' || node === null) {
|
|
192
|
+
throw error_js_1.ContentValidationError.validation('正文包含非法节点');
|
|
193
|
+
}
|
|
194
|
+
if (depth > MAX_DEPTH) {
|
|
195
|
+
throw error_js_1.ContentValidationError.validation('正文嵌套层级过深');
|
|
196
|
+
}
|
|
197
|
+
const n = node;
|
|
198
|
+
const type = n.type;
|
|
199
|
+
if ((type === 'mindMapTopic' || type === 'mindMapRelation') && parentType !== 'mindMap') {
|
|
200
|
+
throw error_js_1.ContentValidationError.validation('导图主题只能放在思维导图内');
|
|
201
|
+
}
|
|
202
|
+
if (['mindMapDescription', 'mindMapNote'].includes(String(type)) && parentType !== 'mindMapTopic')
|
|
203
|
+
throw error_js_1.ContentValidationError.validation('导图描述只能放在主题内');
|
|
204
|
+
if (type === 'mindMap') {
|
|
205
|
+
try {
|
|
206
|
+
(0, mind_map_js_1.validateMindMap)(n);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
throw error_js_1.ContentValidationError.validation(err instanceof Error ? err.message : '思维导图结构无效');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (type === 'callout' && !(0, callout_js_1.validCalloutAttrs)(n.attrs ?? {}))
|
|
213
|
+
throw error_js_1.ContentValidationError.validation('高亮块属性无效');
|
|
214
|
+
if (typeof type === 'string') {
|
|
215
|
+
if (!doc_node_whitelist_js_1.ALLOWED_NODES.has(type)) {
|
|
216
|
+
throw new error_js_1.ContentValidationError(ErrorCode.VALIDATION_FAILED, `正文包含服务端不支持的节点「${type}」,已拒绝保存以避免内容丢失`, 422, { nodeType: type, schemaVersion: exports.SCHEMA_VERSION });
|
|
217
|
+
}
|
|
218
|
+
nodeCount += 1;
|
|
219
|
+
if (nodeCount > MAX_NODES) {
|
|
220
|
+
throw error_js_1.ContentValidationError.validation('正文节点数超出上限');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const out = {};
|
|
224
|
+
if (typeof type === 'string')
|
|
225
|
+
out.type = type;
|
|
226
|
+
// 属性净化:非白名单属性直接丢弃,而非报错(粘贴内容常携带脏属性)
|
|
227
|
+
if (n.attrs && typeof n.attrs === 'object') {
|
|
228
|
+
const allowed = typeof type === 'string' ? ALLOWED_ATTRS[type] : undefined;
|
|
229
|
+
const cleaned = {};
|
|
230
|
+
for (const [k, v] of Object.entries(n.attrs)) {
|
|
231
|
+
if (!allowed || !allowed.has(k))
|
|
232
|
+
continue;
|
|
233
|
+
if (isTableCellType(type) && k === 'backgroundColor') {
|
|
234
|
+
if (typeof v === 'string' && CELL_BACKGROUND_COLORS.has(v))
|
|
235
|
+
cleaned[k] = v;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (k === 'href' && typeof v === 'string') {
|
|
239
|
+
if (!this.isSafeHref(v))
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// 表格单元格的数值属性必须强类型化:字符串形态会让
|
|
243
|
+
// prosemirror-tables 崩溃、整篇正文渲染失败(见 POSITIVE_INT_ATTRS 注释)
|
|
244
|
+
if (isTableCellType(type) && POSITIVE_INT_ATTRS.has(k)) {
|
|
245
|
+
const coerced = coercePositiveInt(v);
|
|
246
|
+
if (coerced === undefined)
|
|
247
|
+
continue;
|
|
248
|
+
cleaned[k] = coerced;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (isTableCellType(type) && k === 'colwidth') {
|
|
252
|
+
const coerced = coerceColwidth(v);
|
|
253
|
+
if (coerced === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
cleaned[k] = coerced;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
cleaned[k] = v;
|
|
259
|
+
}
|
|
260
|
+
if (Object.keys(cleaned).length > 0)
|
|
261
|
+
out.attrs = cleaned;
|
|
262
|
+
}
|
|
263
|
+
if (type === 'videoEmbed') {
|
|
264
|
+
const video = (0, video_embed_js_1.normalizeVideoEmbed)(n.attrs);
|
|
265
|
+
if (!video)
|
|
266
|
+
throw error_js_1.ContentValidationError.validation('视频属性无效,请重新插入视频');
|
|
267
|
+
out.attrs = { ...video };
|
|
268
|
+
}
|
|
269
|
+
if (typeof n.text === 'string')
|
|
270
|
+
out.text = n.text;
|
|
271
|
+
// 标记净化
|
|
272
|
+
if (Array.isArray(n.marks)) {
|
|
273
|
+
const marks = n.marks
|
|
274
|
+
.filter((m) => typeof m.type === 'string' && doc_node_whitelist_js_1.ALLOWED_MARKS.has(m.type))
|
|
275
|
+
.map((m) => {
|
|
276
|
+
const mm = { type: m.type };
|
|
277
|
+
if (m.attrs && typeof m.attrs === 'object') {
|
|
278
|
+
const allowed = ALLOWED_ATTRS[m.type];
|
|
279
|
+
const cleaned = {};
|
|
280
|
+
for (const [k, v] of Object.entries(m.attrs)) {
|
|
281
|
+
if (!allowed?.has(k))
|
|
282
|
+
continue;
|
|
283
|
+
// 链接标记的 href 同样必须过协议白名单。
|
|
284
|
+
// 注意:这里曾漏检,导致正文内超链接可携带 javascript: 协议
|
|
285
|
+
// 绕过服务端校验(由 doc-schema 单测发现)。
|
|
286
|
+
if (k === 'href') {
|
|
287
|
+
if (typeof v !== 'string' || !this.isSafeHref(v))
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
cleaned[k] = v;
|
|
291
|
+
}
|
|
292
|
+
if (Object.keys(cleaned).length > 0)
|
|
293
|
+
mm.attrs = cleaned;
|
|
294
|
+
}
|
|
295
|
+
return mm;
|
|
296
|
+
});
|
|
297
|
+
if (marks.length > 0)
|
|
298
|
+
out.marks = marks;
|
|
299
|
+
}
|
|
300
|
+
// 派生提取
|
|
301
|
+
if (type === 'pageLink' && n.attrs) {
|
|
302
|
+
const pid = n.attrs.pageId;
|
|
303
|
+
if (typeof pid === 'string' && pid.length > 0)
|
|
304
|
+
linkTargetIds.add(pid);
|
|
305
|
+
}
|
|
306
|
+
if ((type === 'image' || type === 'mindMapTopic') && n.attrs) {
|
|
307
|
+
const aid = n.attrs.attachmentId;
|
|
308
|
+
if (typeof aid === 'string' && aid.length > 0) {
|
|
309
|
+
attachmentIds.add(aid);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// 视频/文件卡片同样引用附件:必须一并收集,否则这些附件会被
|
|
313
|
+
// 附件引用计数与孤儿清理误判为「无人引用」而删掉。
|
|
314
|
+
if (type === 'fileAttachment' && n.attrs) {
|
|
315
|
+
const aid = n.attrs.attachmentId;
|
|
316
|
+
if (typeof aid === 'string' && aid.length > 0) {
|
|
317
|
+
attachmentIds.add(aid);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// 公式源码长度上限:LaTeX 渲染复杂度可被超长输入放大
|
|
321
|
+
if ((type === 'mathInline' || type === 'mathBlock') && n.attrs) {
|
|
322
|
+
const latex = n.attrs.latex;
|
|
323
|
+
if (typeof latex === 'string' && latex.length > 5000) {
|
|
324
|
+
throw error_js_1.ContentValidationError.validation('公式内容过长(上限 5000 字符)');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (type === 'mention' && n.attrs) {
|
|
328
|
+
const uid = n.attrs.userId;
|
|
329
|
+
if (typeof uid === 'string' && uid.length > 0) {
|
|
330
|
+
mentionUserIds.add(uid);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (Array.isArray(n.content)) {
|
|
334
|
+
out.content = n.content
|
|
335
|
+
.filter((c) => c !== null && c !== undefined)
|
|
336
|
+
.map((c) => walk(c, depth + 1, typeof type === 'string' ? type : ''));
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
};
|
|
340
|
+
const doc = walk(raw, 0);
|
|
341
|
+
return {
|
|
342
|
+
doc,
|
|
343
|
+
schemaVersion: exports.SCHEMA_VERSION,
|
|
344
|
+
linkTargetIds: [...linkTargetIds],
|
|
345
|
+
attachmentIds: [...attachmentIds],
|
|
346
|
+
mentionUserIds: [...mentionUserIds],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/** 仅允许 http/https/mailto 与站内相对链接,阻断 javascript: 等危险协议。 */
|
|
350
|
+
isSafeHref(href) {
|
|
351
|
+
const trimmed = href.trim().toLowerCase();
|
|
352
|
+
if (trimmed.startsWith('/') || trimmed.startsWith('#'))
|
|
353
|
+
return true;
|
|
354
|
+
return (trimmed.startsWith('http://') ||
|
|
355
|
+
trimmed.startsWith('https://') ||
|
|
356
|
+
trimmed.startsWith('mailto:'));
|
|
357
|
+
}
|
|
358
|
+
/** 纯文本投影:供 M0 搜索与摘要使用(不含 HTML,避免 XSS 面)。 */
|
|
359
|
+
extractPlainText(doc) {
|
|
360
|
+
if ((0, workbook_js_1.isWorkbookDocument)(doc))
|
|
361
|
+
return (0, workbook_js_1.workbookText)(doc);
|
|
362
|
+
const buf = [];
|
|
363
|
+
const walk = (node) => {
|
|
364
|
+
if (typeof node !== 'object' || node === null)
|
|
365
|
+
return;
|
|
366
|
+
const n = node;
|
|
367
|
+
if (typeof n.text === 'string')
|
|
368
|
+
buf.push(n.text);
|
|
369
|
+
if (Array.isArray(n.content))
|
|
370
|
+
n.content.forEach(walk);
|
|
371
|
+
};
|
|
372
|
+
walk(doc);
|
|
373
|
+
return buf.join(' ').replace(/\s+/g, ' ').trim();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
exports.ContentSchemaService = ContentSchemaService;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("../shared/video-embed.js"), exports);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("../shared/workbook.js"), exports);
|