@portone/docx-editor 0.1.1 → 0.2.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/CHANGELOG.md +82 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +17 -5
- package/dist/DocxEditor.d.ts +34 -10
- package/dist/DocxEditor.js +69 -24
- package/dist/core.d.ts +2 -0
- package/dist/core.js +2 -0
- package/dist/docx/commentOnlyChange.d.ts +47 -0
- package/dist/docx/commentOnlyChange.js +162 -0
- package/dist/docx/comments/constants.d.ts +11 -3
- package/dist/docx/comments/constants.js +10 -3
- package/dist/docx/comments/contentTypes.d.ts +7 -0
- package/dist/docx/comments/contentTypes.js +38 -0
- package/dist/docx/comments/grammar.d.ts +80 -0
- package/dist/docx/comments/grammar.js +213 -0
- package/dist/docx/comments/model.d.ts +2 -0
- package/dist/docx/comments/model.js +3 -0
- package/dist/docx/comments/people.d.ts +39 -0
- package/dist/docx/comments/people.js +196 -0
- package/dist/docx/comments/reading.d.ts +18 -1
- package/dist/docx/comments/reading.js +33 -20
- package/dist/docx/comments/verifying.d.ts +44 -0
- package/dist/docx/comments/verifying.js +206 -0
- package/dist/docx/comments/writing.d.ts +4 -1
- package/dist/docx/comments/writing.js +84 -77
- package/dist/docx/exportDocx.js +2 -1
- package/dist/docx/formatting/direct.js +3 -6
- package/dist/docx/headersFooters.js +8 -10
- package/dist/docx/importParagraph.js +1 -0
- package/dist/docx/notes.js +3 -5
- package/dist/docx/paraProps.js +51 -46
- package/dist/docx/propsXml.d.ts +20 -2
- package/dist/docx/propsXml.js +72 -32
- package/dist/docx/relationships.js +8 -3
- package/dist/docx/runProps.js +34 -34
- package/dist/docx/sdt.js +16 -14
- package/dist/docx/serializeBlock.js +1 -2
- package/dist/docx/serializeParagraph.d.ts +2 -1
- package/dist/docx/serializeParagraph.js +25 -14
- package/dist/docx/serializeTable.js +38 -24
- package/dist/docx/storyProjection.d.ts +36 -0
- package/dist/docx/storyProjection.js +23 -0
- package/dist/docx/tableFormatting/editing.js +49 -73
- package/dist/docx/tableTemplate.js +24 -3
- package/dist/docx/theme.d.ts +0 -6
- package/dist/docx/theme.js +0 -8
- package/dist/editor/clipboard/images.js +17 -3
- package/dist/editor/clipboard/inlineFormatting.d.ts +7 -0
- package/dist/editor/clipboard/inlineFormatting.js +1 -0
- package/dist/editor/commands/breakCommands.js +6 -2
- package/dist/editor/commands/canRunCommand.d.ts +3 -2
- package/dist/editor/commands/canRunCommand.js +2 -2
- package/dist/editor/commands/comments/editing.js +9 -10
- package/dist/editor/commands/comments/model.d.ts +9 -0
- package/dist/editor/commands/comments/reading.d.ts +7 -0
- package/dist/editor/commands/comments/reading.js +14 -0
- package/dist/editor/commands/formatting/editing.js +6 -1
- package/dist/editor/commands/historyCommands.js +14 -5
- package/dist/editor/commands/index.d.ts +7 -1
- package/dist/editor/commands/index.js +4 -0
- package/dist/editor/commands/linkCommands.js +2 -0
- package/dist/editor/commands/lockCommands.js +12 -1
- package/dist/editor/commands/tabCommands.js +4 -2
- package/dist/editor/createEditor.d.ts +13 -3
- package/dist/editor/createEditor.js +23 -15
- package/dist/editor/externalClipboard.js +166 -4
- package/dist/editor/insertImage.js +6 -2
- package/dist/editor/insertTable.js +2 -1
- package/dist/editor/paragraphEdits.d.ts +2 -0
- package/dist/editor/paragraphEdits.js +2 -0
- package/dist/editor/plugins/documentProtection.d.ts +21 -0
- package/dist/editor/plugins/documentProtection.js +44 -0
- package/dist/editor/plugins/imagePaste.js +4 -1
- package/dist/editor/plugins/keymap.d.ts +11 -1
- package/dist/editor/plugins/keymap.js +24 -4
- package/dist/editor/plugins/lockedContent.d.ts +4 -2
- package/dist/editor/plugins/lockedContent.js +2 -2
- package/dist/editor/plugins/tableContextMenu.js +2 -1
- package/dist/editor/plugins/textContextMenu.js +6 -1
- package/dist/index.d.ts +2 -0
- package/dist/numbering/listTemplate.js +32 -10
- package/dist/ooxml/element.d.ts +52 -0
- package/dist/ooxml/element.js +49 -0
- package/dist/ooxml/fragment.d.ts +53 -0
- package/dist/ooxml/fragment.js +76 -0
- package/dist/ooxml/image.js +4 -3
- package/dist/ooxml/names.d.ts +27 -0
- package/dist/ooxml/names.js +29 -0
- package/dist/ooxml/precedence.d.ts +31 -0
- package/dist/ooxml/precedence.js +51 -0
- package/dist/ooxml/xml.d.ts +31 -0
- package/dist/ooxml/xml.js +27 -0
- package/dist/page/blockKinds.d.ts +42 -0
- package/dist/page/blockKinds.js +0 -0
- package/dist/page/measureBlocks.d.ts +14 -1
- package/dist/page/measureBlocks.js +31 -11
- package/dist/page/pageDecorations.d.ts +16 -0
- package/dist/page/pageDecorations.js +1 -0
- package/dist/page/pageLayout.d.ts +8 -43
- package/dist/page/pageLayout.js +28 -51
- package/dist/page/tableMeasurements.d.ts +10 -2
- package/dist/page/tableMeasurements.js +20 -16
- package/dist/page/usePageLayout.d.ts +15 -0
- package/dist/page/usePageLayout.js +30 -2
- package/dist/schema/attrRoles.d.ts +35 -0
- package/dist/schema/attrRoles.js +106 -0
- package/dist/schema/docxSchema.d.ts +6 -0
- package/dist/schema/docxSchema.js +216 -100
- package/dist/schema/editGuard.d.ts +99 -0
- package/dist/schema/editGuard.js +43 -0
- package/dist/schema/guards.d.ts +68 -0
- package/dist/schema/guards.js +82 -0
- package/dist/schema/locks.d.ts +9 -8
- package/dist/schema/locks.js +32 -16
- package/dist/schema/preservedGuards.d.ts +30 -0
- package/dist/schema/preservedGuards.js +50 -0
- package/dist/schema/protection.d.ts +84 -0
- package/dist/schema/protection.js +171 -0
- package/dist/schema/protectionState.d.ts +20 -0
- package/dist/schema/protectionState.js +37 -0
- package/dist/schema/sourceEquality.d.ts +27 -0
- package/dist/schema/sourceEquality.js +65 -0
- package/dist/table/cellFormatting.js +5 -2
- package/dist/table/commands.d.ts +2 -2
- package/dist/table/commands.js +2 -2
- package/dist/table/index.d.ts +2 -2
- package/dist/table/merge.d.ts +2 -2
- package/dist/table/merge.js +3 -3
- package/dist/ui/CommentsPanel.d.ts +3 -3
- package/dist/ui/CommentsPanel.js +26 -18
- package/dist/ui/LinkCard.d.ts +1 -2
- package/dist/ui/LinkCard.js +3 -4
- package/dist/ui/TextMenu.js +15 -10
- package/package.json +5 -2
- package/dist/editor/plugins/bookmarkProtection.d.ts +0 -3
- package/dist/editor/plugins/bookmarkProtection.js +0 -28
- package/dist/editor/plugins/noteProtection.d.ts +0 -3
- package/dist/editor/plugins/noteProtection.js +0 -25
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/docx/comments/people.ts
|
|
2
|
+
import { xmlnsAttr } from "../../ooxml/element.js";
|
|
3
|
+
import { DocxExportError } from "../../ooxml/errors.js";
|
|
4
|
+
import { xmlnsDecl } from "../../ooxml/names.js";
|
|
5
|
+
import {
|
|
6
|
+
attributeByLocalName,
|
|
7
|
+
childByLocalName,
|
|
8
|
+
decodeUtf8,
|
|
9
|
+
elementChildren,
|
|
10
|
+
encodeUtf8,
|
|
11
|
+
parseXml
|
|
12
|
+
} from "../../ooxml/xml.js";
|
|
13
|
+
import {
|
|
14
|
+
directoryOf,
|
|
15
|
+
readRelationships,
|
|
16
|
+
relsPathOf,
|
|
17
|
+
resolveTarget
|
|
18
|
+
} from "../relationships.js";
|
|
19
|
+
import {
|
|
20
|
+
COMMENT_AUTHOR_PROVIDER,
|
|
21
|
+
CONTENT_TYPES_PATH,
|
|
22
|
+
PEOPLE_CONTENT_TYPE,
|
|
23
|
+
PEOPLE_REL_TYPE,
|
|
24
|
+
W15_NS
|
|
25
|
+
} from "./constants.js";
|
|
26
|
+
import { withContentType } from "./contentTypes.js";
|
|
27
|
+
import { renderPerson } from "./grammar.js";
|
|
28
|
+
var NO_PEOPLE = {
|
|
29
|
+
partPath: null,
|
|
30
|
+
xml: null,
|
|
31
|
+
hadBom: false,
|
|
32
|
+
byAuthor: /* @__PURE__ */ new Map()
|
|
33
|
+
};
|
|
34
|
+
function readPeople(parts, mainPartPath) {
|
|
35
|
+
const relationship = readRelationships(parts, relsPathOf(mainPartPath)).find(
|
|
36
|
+
(entry) => entry.type === PEOPLE_REL_TYPE && !entry.external
|
|
37
|
+
);
|
|
38
|
+
if (!relationship) return NO_PEOPLE;
|
|
39
|
+
const partPath = resolveTarget(mainPartPath, relationship.target);
|
|
40
|
+
const bytes = parts.get(partPath);
|
|
41
|
+
if (!bytes) return { ...NO_PEOPLE, partPath };
|
|
42
|
+
const { text, hadBom } = decodeUtf8(bytes);
|
|
43
|
+
const root = parseXml(text).documentElement;
|
|
44
|
+
const ourIds = /* @__PURE__ */ new Map();
|
|
45
|
+
const byAuthor = /* @__PURE__ */ new Map();
|
|
46
|
+
for (const el of elementChildren(root)) {
|
|
47
|
+
if (el.localName !== "person") continue;
|
|
48
|
+
const author = attributeByLocalName(el, "author");
|
|
49
|
+
if (author === null) continue;
|
|
50
|
+
byAuthor.set(author, null);
|
|
51
|
+
const presence = childByLocalName(el, "presenceInfo");
|
|
52
|
+
if (presence === null) continue;
|
|
53
|
+
if (attributeByLocalName(presence, "providerId") !== COMMENT_AUTHOR_PROVIDER)
|
|
54
|
+
continue;
|
|
55
|
+
const ids = ourIds.get(author) ?? /* @__PURE__ */ new Set();
|
|
56
|
+
ids.add(attributeByLocalName(presence, "userId"));
|
|
57
|
+
ourIds.set(author, ids);
|
|
58
|
+
}
|
|
59
|
+
for (const [author, ids] of ourIds) {
|
|
60
|
+
if (ids.size !== 1) continue;
|
|
61
|
+
byAuthor.set(author, Array.from(ids)[0] ?? null);
|
|
62
|
+
}
|
|
63
|
+
return { partPath, xml: text, hadBom, byAuthor };
|
|
64
|
+
}
|
|
65
|
+
function commentAuthorId(people, author) {
|
|
66
|
+
return people.byAuthor.get(author) ?? null;
|
|
67
|
+
}
|
|
68
|
+
function unrecordedAuthors(bodies, people) {
|
|
69
|
+
const unrecorded = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const body of bodies) {
|
|
71
|
+
if (body.author === null || body.authorId === null) continue;
|
|
72
|
+
if (people.byAuthor.has(body.author)) continue;
|
|
73
|
+
unrecorded.set(body.author, body.authorId);
|
|
74
|
+
}
|
|
75
|
+
return unrecorded;
|
|
76
|
+
}
|
|
77
|
+
function w15Prefix(root) {
|
|
78
|
+
if (root.namespaceURI === W15_NS) {
|
|
79
|
+
return root.prefix === null ? "" : `${root.prefix}:`;
|
|
80
|
+
}
|
|
81
|
+
const declaration = Array.from(root.attributes).find(
|
|
82
|
+
(attr) => attr.value === W15_NS && (attr.name === "xmlns" || attr.name.startsWith("xmlns:"))
|
|
83
|
+
);
|
|
84
|
+
if (declaration === void 0) return null;
|
|
85
|
+
const prefix = declaration.name.slice("xmlns:".length);
|
|
86
|
+
return prefix === "" ? "" : `${prefix}:`;
|
|
87
|
+
}
|
|
88
|
+
function rootTagStart(xml) {
|
|
89
|
+
let at = 0;
|
|
90
|
+
for (; ; ) {
|
|
91
|
+
const opens = xml.indexOf("<", at);
|
|
92
|
+
if (opens === -1) return -1;
|
|
93
|
+
if (xml.startsWith("<!--", opens)) {
|
|
94
|
+
at = xml.indexOf("-->", opens + 4) + 3;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (xml.startsWith("<?", opens)) {
|
|
98
|
+
at = xml.indexOf("?>", opens + 2) + 2;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
return opens;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function openTagEnd(xml, at) {
|
|
105
|
+
let quote = null;
|
|
106
|
+
for (let i = at; i < xml.length; i += 1) {
|
|
107
|
+
const character = xml[i];
|
|
108
|
+
if (quote !== null) {
|
|
109
|
+
if (character === quote) quote = null;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (character === '"' || character === "'") {
|
|
113
|
+
quote = character;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (character === ">") {
|
|
117
|
+
return { end: i + 1, selfClosing: xml[i - 1] === "/" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function malformed(detail) {
|
|
123
|
+
return new DocxExportError("malformed-xml", `the people part ${detail}`);
|
|
124
|
+
}
|
|
125
|
+
function peopleXml(people, added) {
|
|
126
|
+
const xml = people.xml;
|
|
127
|
+
if (xml === null) {
|
|
128
|
+
const persons2 = Array.from(
|
|
129
|
+
added,
|
|
130
|
+
([author, userId]) => renderPerson(author, userId, "w15:", null)
|
|
131
|
+
);
|
|
132
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:people ${xmlnsDecl("w15")}>${persons2.join("")}</w15:people>`;
|
|
133
|
+
}
|
|
134
|
+
const root = parseXml(xml).documentElement;
|
|
135
|
+
if (root.localName !== "people") {
|
|
136
|
+
throw malformed("has no people root element");
|
|
137
|
+
}
|
|
138
|
+
const prefix = w15Prefix(root);
|
|
139
|
+
const persons = Array.from(
|
|
140
|
+
added,
|
|
141
|
+
([author, userId]) => renderPerson(
|
|
142
|
+
author,
|
|
143
|
+
userId,
|
|
144
|
+
prefix ?? "w15:",
|
|
145
|
+
prefix === null ? xmlnsAttr("w15") : null
|
|
146
|
+
)
|
|
147
|
+
).join("");
|
|
148
|
+
const start = rootTagStart(xml);
|
|
149
|
+
const open = start === -1 ? null : openTagEnd(xml, start);
|
|
150
|
+
if (open === null) throw malformed("has no people root element");
|
|
151
|
+
if (open.selfClosing) {
|
|
152
|
+
return `${xml.slice(0, open.end - 2)}>${persons}</${root.nodeName}>` + xml.slice(open.end);
|
|
153
|
+
}
|
|
154
|
+
const close = xml.lastIndexOf(`</${root.nodeName}`);
|
|
155
|
+
if (close === -1) throw malformed("has no closing people tag");
|
|
156
|
+
return xml.slice(0, close) + persons + xml.slice(close);
|
|
157
|
+
}
|
|
158
|
+
function availablePeoplePath(session) {
|
|
159
|
+
const directory = directoryOf(session.mainPartPath);
|
|
160
|
+
for (let suffix = 0; ; suffix += 1) {
|
|
161
|
+
const name = suffix === 0 ? "people.xml" : `people${suffix + 1}.xml`;
|
|
162
|
+
const path = directory + name;
|
|
163
|
+
if (!session.parts.has(path)) return path;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function planPeoplePart(bodies, session, relationships, currentContentTypes) {
|
|
167
|
+
const people = session.comments.people;
|
|
168
|
+
const added = unrecordedAuthors(bodies, people);
|
|
169
|
+
if (added.size === 0) return null;
|
|
170
|
+
const parts = /* @__PURE__ */ new Map();
|
|
171
|
+
const addingPart = people.partPath === null;
|
|
172
|
+
const partPath = people.partPath ?? availablePeoplePath(session);
|
|
173
|
+
if (addingPart) {
|
|
174
|
+
relationships.add({
|
|
175
|
+
type: PEOPLE_REL_TYPE,
|
|
176
|
+
target: partPath.slice(directoryOf(session.mainPartPath).length)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
parts.set(partPath, encodeUtf8(peopleXml(people, added), people.hadBom));
|
|
180
|
+
if (addingPart || people.xml === null) {
|
|
181
|
+
const contentTypes = withContentType(
|
|
182
|
+
session.parts,
|
|
183
|
+
partPath,
|
|
184
|
+
PEOPLE_CONTENT_TYPE,
|
|
185
|
+
currentContentTypes
|
|
186
|
+
);
|
|
187
|
+
if (contentTypes) parts.set(CONTENT_TYPES_PATH, contentTypes);
|
|
188
|
+
}
|
|
189
|
+
return parts;
|
|
190
|
+
}
|
|
191
|
+
export {
|
|
192
|
+
NO_PEOPLE,
|
|
193
|
+
commentAuthorId,
|
|
194
|
+
planPeoplePart,
|
|
195
|
+
readPeople
|
|
196
|
+
};
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reads comment and comment-extension package parts.
|
|
3
3
|
*/
|
|
4
|
+
import { type ImportedPeople } from "./people";
|
|
4
5
|
export interface ImportedComment {
|
|
5
6
|
id: string;
|
|
6
7
|
author: string | null;
|
|
8
|
+
/**
|
|
9
|
+
* The identity the people part records for `author` under this editor's provider. Null when it
|
|
10
|
+
* records none, and null as well when it records that name under more than one identity, which
|
|
11
|
+
* leaves no way to tell whose comment this is.
|
|
12
|
+
*/
|
|
13
|
+
authorId: string | null;
|
|
7
14
|
initials: string | null;
|
|
8
15
|
date: string | null;
|
|
9
16
|
text: string;
|
|
@@ -24,6 +31,8 @@ export interface ImportedComments {
|
|
|
24
31
|
extendedXml: string | null;
|
|
25
32
|
extendedHadBom: boolean;
|
|
26
33
|
extendedOrdered: readonly ImportedCommentExtension[];
|
|
34
|
+
/** The people part as opened, which is where an author's identity is looked up */
|
|
35
|
+
people: ImportedPeople;
|
|
27
36
|
}
|
|
28
37
|
export declare const NO_COMMENTS: ImportedComments;
|
|
29
38
|
export interface ImportedCommentExtension {
|
|
@@ -32,5 +41,13 @@ export interface ImportedCommentExtension {
|
|
|
32
41
|
resolved: boolean;
|
|
33
42
|
xml: string;
|
|
34
43
|
}
|
|
35
|
-
/**
|
|
44
|
+
/** The thread key of a comment, which the writer puts on the last paragraph of its body */
|
|
45
|
+
export declare function lastParagraphId(comment: Element): string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Reads the Comments part related from the main document story.
|
|
48
|
+
*
|
|
49
|
+
* The people part and the extended part are read whether or not there are comments: a document may
|
|
50
|
+
* carry either with no comment left, and a comment added to it then has to be written into the
|
|
51
|
+
* part it already has rather than into a second one beside it.
|
|
52
|
+
*/
|
|
36
53
|
export declare function readComments(parts: Map<string, Uint8Array>, mainPartPath: string): ImportedComments;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/docx/comments/reading.ts
|
|
2
2
|
import {
|
|
3
|
+
attributeByLocalName,
|
|
3
4
|
decodeUtf8,
|
|
4
5
|
elementChildren,
|
|
5
6
|
parseXml,
|
|
@@ -8,9 +9,12 @@ import {
|
|
|
8
9
|
} from "../../ooxml/xml.js";
|
|
9
10
|
import { readRelationships, relsPathOf, resolveTarget } from "../relationships.js";
|
|
10
11
|
import { COMMENTS_EXTENDED_REL_TYPE, COMMENTS_REL_TYPE } from "./constants.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { lastBodyParagraph } from "./grammar.js";
|
|
13
|
+
import {
|
|
14
|
+
commentAuthorId,
|
|
15
|
+
NO_PEOPLE,
|
|
16
|
+
readPeople
|
|
17
|
+
} from "./people.js";
|
|
14
18
|
function inlineCommentText(node) {
|
|
15
19
|
if (node.localName === "t") return node.textContent ?? "";
|
|
16
20
|
if (node.localName === "tab") return " ";
|
|
@@ -31,11 +35,12 @@ var NO_COMMENTS = {
|
|
|
31
35
|
extendedPartPath: null,
|
|
32
36
|
extendedXml: null,
|
|
33
37
|
extendedHadBom: false,
|
|
34
|
-
extendedOrdered: []
|
|
38
|
+
extendedOrdered: [],
|
|
39
|
+
people: NO_PEOPLE
|
|
35
40
|
};
|
|
36
41
|
function lastParagraphId(comment) {
|
|
37
|
-
const
|
|
38
|
-
return
|
|
42
|
+
const last = lastBodyParagraph(comment);
|
|
43
|
+
return last === null ? null : attributeByLocalName(last, "paraId");
|
|
39
44
|
}
|
|
40
45
|
function readCommentExtensions(parts, mainPartPath) {
|
|
41
46
|
const relationship = readRelationships(parts, relsPathOf(mainPartPath)).find(
|
|
@@ -67,13 +72,13 @@ function readCommentExtensions(parts, mainPartPath) {
|
|
|
67
72
|
const ordered = [];
|
|
68
73
|
for (const el of elementChildren(root)) {
|
|
69
74
|
if (el.localName !== "commentEx") continue;
|
|
70
|
-
const paraId =
|
|
75
|
+
const paraId = attributeByLocalName(el, "paraId");
|
|
71
76
|
if (paraId === null) continue;
|
|
72
77
|
const extension = {
|
|
73
78
|
paraId,
|
|
74
|
-
parentParaId:
|
|
79
|
+
parentParaId: attributeByLocalName(el, "paraIdParent"),
|
|
75
80
|
resolved: ["1", "true", "on"].includes(
|
|
76
|
-
|
|
81
|
+
attributeByLocalName(el, "done")?.toLowerCase() ?? ""
|
|
77
82
|
),
|
|
78
83
|
xml: serializeXml(el)
|
|
79
84
|
};
|
|
@@ -83,27 +88,37 @@ function readCommentExtensions(parts, mainPartPath) {
|
|
|
83
88
|
return { partPath, xml: text, hadBom, byParaId, ordered };
|
|
84
89
|
}
|
|
85
90
|
function readComments(parts, mainPartPath) {
|
|
91
|
+
const people = readPeople(parts, mainPartPath);
|
|
92
|
+
const extensions = readCommentExtensions(parts, mainPartPath);
|
|
93
|
+
const aside = {
|
|
94
|
+
people,
|
|
95
|
+
extendedPartPath: extensions.partPath,
|
|
96
|
+
extendedXml: extensions.xml,
|
|
97
|
+
extendedHadBom: extensions.hadBom,
|
|
98
|
+
extendedOrdered: extensions.ordered
|
|
99
|
+
};
|
|
86
100
|
const relationship = readRelationships(parts, relsPathOf(mainPartPath)).find(
|
|
87
101
|
(entry) => entry.type === COMMENTS_REL_TYPE && !entry.external
|
|
88
102
|
);
|
|
89
|
-
if (!relationship) return NO_COMMENTS;
|
|
103
|
+
if (!relationship) return { ...NO_COMMENTS, ...aside };
|
|
90
104
|
const partPath = resolveTarget(mainPartPath, relationship.target);
|
|
91
105
|
const bytes = parts.get(partPath);
|
|
92
|
-
if (!bytes) return { ...NO_COMMENTS, partPath };
|
|
106
|
+
if (!bytes) return { ...NO_COMMENTS, partPath, ...aside };
|
|
93
107
|
const { text, hadBom } = decodeUtf8(bytes);
|
|
94
108
|
const root = parseXml(text).documentElement;
|
|
95
|
-
const extensions = readCommentExtensions(parts, mainPartPath);
|
|
96
109
|
const base = elementChildren(root).filter((el) => el.localName === "comment").flatMap((el) => {
|
|
97
|
-
const id =
|
|
110
|
+
const id = attributeByLocalName(el, "id");
|
|
98
111
|
if (id === null) return [];
|
|
99
112
|
const paraId = lastParagraphId(el);
|
|
100
113
|
const extension = paraId ? extensions.byParaId.get(paraId) : void 0;
|
|
114
|
+
const author = attributeByLocalName(el, "author");
|
|
101
115
|
return [
|
|
102
116
|
{
|
|
103
117
|
id,
|
|
104
|
-
author
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
author,
|
|
119
|
+
authorId: author === null ? null : commentAuthorId(people, author),
|
|
120
|
+
initials: attributeByLocalName(el, "initials"),
|
|
121
|
+
date: attributeByLocalName(el, "date"),
|
|
107
122
|
text: commentText(el),
|
|
108
123
|
xml: serializeXml(el),
|
|
109
124
|
paraId,
|
|
@@ -137,13 +152,11 @@ function readComments(parts, mainPartPath) {
|
|
|
137
152
|
ordered,
|
|
138
153
|
byId,
|
|
139
154
|
repliesByParentId,
|
|
140
|
-
|
|
141
|
-
extendedXml: extensions.xml,
|
|
142
|
-
extendedHadBom: extensions.hadBom,
|
|
143
|
-
extendedOrdered: extensions.ordered
|
|
155
|
+
...aside
|
|
144
156
|
};
|
|
145
157
|
}
|
|
146
158
|
export {
|
|
147
159
|
NO_COMMENTS,
|
|
160
|
+
lastParagraphId,
|
|
148
161
|
readComments
|
|
149
162
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether every entry of the three comment parts is one this editor wrote, for an author who was
|
|
3
|
+
* in a position to write it.
|
|
4
|
+
*
|
|
5
|
+
* The parts a comment is written across are the ones a submission may rewrite, so the package
|
|
6
|
+
* comparison (`../commentOnlyChange`) leaves their bytes alone. Something has to read them, or a
|
|
7
|
+
* file could carry anything at all inside a comment and still be answered as unchanged. What that
|
|
8
|
+
* reading holds an entry to is two separate questions. Grammar asks whether this editor's writer
|
|
9
|
+
* could have put the entry out, whoever it belongs to. Permission asks whether this author could
|
|
10
|
+
* have made the change. Asking them as one would refuse a moderator rewriting somebody else's
|
|
11
|
+
* comment under `editableComments: "all"`, which is a thing the editor lets them do.
|
|
12
|
+
*/
|
|
13
|
+
import type { EditableComments } from "../../schema/protection";
|
|
14
|
+
import type { CommentOnlyVerdict } from "../commentOnlyChange";
|
|
15
|
+
import type { Story } from "../storyProjection";
|
|
16
|
+
import { type ImportedPeople } from "./people";
|
|
17
|
+
/**
|
|
18
|
+
* Whether this editor's writer could have put the entry out, whoever it belongs to.
|
|
19
|
+
*
|
|
20
|
+
* Shape alone: which attributes it carries and what stands inside it. Who may have written it is
|
|
21
|
+
* `entryAllowed`'s question. Each kind is read where it is written (`./grammar`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function wellFormedEntry(entry: Element): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether `authorId` could have written or rewritten this entry.
|
|
26
|
+
*
|
|
27
|
+
* Permission alone: whether the shape is one this editor writes is `wellFormedEntry`'s question.
|
|
28
|
+
* An entry that appeared has to be this author's. One that arrived keeps the identity it arrived
|
|
29
|
+
* with; settling its thread or replying to it belong to everyone and leave what it says alone,
|
|
30
|
+
* while rewriting what it says is its author's, a moderator's, or anyone's where no identity was
|
|
31
|
+
* recorded for it. That last is the rule the editor holds to (`schema/protection`), and the two
|
|
32
|
+
* have to answer alike or a file the editor wrote would be turned down here.
|
|
33
|
+
*/
|
|
34
|
+
export declare function entryAllowed(entry: Element, original: Element | null, authorId: string, editableComments: EditableComments, people: ImportedPeople): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the three comment parts came back holding only entries this editor writes, each of them
|
|
37
|
+
* one this author was in a position to write.
|
|
38
|
+
*
|
|
39
|
+
* This runs after the story has been judged, so a comment rewritten, re-anchored or deleted by the
|
|
40
|
+
* wrong hand is already named for what it is (`../commentOnlyChange`). What is left to this is
|
|
41
|
+
* everything the story cannot show: markup forged into a body, an entry nothing refers to, an
|
|
42
|
+
* identity recorded for somebody else.
|
|
43
|
+
*/
|
|
44
|
+
export declare function commentPartsKept(before: Story, after: Story, authorId: string, editableComments: EditableComments): CommentOnlyVerdict;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// src/docx/comments/verifying.ts
|
|
2
|
+
import {
|
|
3
|
+
attributeByLocalName,
|
|
4
|
+
elementChildren,
|
|
5
|
+
isElement,
|
|
6
|
+
parseXml,
|
|
7
|
+
serializeXml,
|
|
8
|
+
W_NS
|
|
9
|
+
} from "../../ooxml/xml.js";
|
|
10
|
+
import { W14_NS, W15_NS } from "./constants.js";
|
|
11
|
+
import {
|
|
12
|
+
attributesWithin,
|
|
13
|
+
COMMENT_ATTRIBUTES,
|
|
14
|
+
lastBodyParagraph,
|
|
15
|
+
readStrictCommentBody,
|
|
16
|
+
recordedIdentity,
|
|
17
|
+
wellFormedCommentExtension,
|
|
18
|
+
wellFormedPerson
|
|
19
|
+
} from "./grammar.js";
|
|
20
|
+
import { commentReferencesIn } from "./model.js";
|
|
21
|
+
import { commentAuthorId } from "./people.js";
|
|
22
|
+
import { lastParagraphId } from "./reading.js";
|
|
23
|
+
var COMMENT_IDENTITY = [
|
|
24
|
+
"id",
|
|
25
|
+
"author",
|
|
26
|
+
"date",
|
|
27
|
+
"initials"
|
|
28
|
+
];
|
|
29
|
+
function sameAttributes(entry, original, names) {
|
|
30
|
+
return names.every(
|
|
31
|
+
(name) => attributeByLocalName(entry, name) === attributeByLocalName(original, name)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function arrivedEntries(xml, localName, idAttr) {
|
|
35
|
+
const entries = /* @__PURE__ */ new Map();
|
|
36
|
+
if (xml === null) return entries;
|
|
37
|
+
for (const el of elementChildren(parseXml(xml).documentElement)) {
|
|
38
|
+
if (el.localName !== localName) continue;
|
|
39
|
+
const id = attributeByLocalName(el, idAttr);
|
|
40
|
+
if (id === null || entries.has(id)) continue;
|
|
41
|
+
entries.set(id, { el, xml: serializeXml(el) });
|
|
42
|
+
}
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
function submittedEntries(xml, namespace, localName, idAttr) {
|
|
46
|
+
if (xml === null) return [];
|
|
47
|
+
const entries = [];
|
|
48
|
+
const seen = /* @__PURE__ */ new Set();
|
|
49
|
+
for (const el of elementChildren(parseXml(xml).documentElement)) {
|
|
50
|
+
if (el.namespaceURI !== namespace || el.localName !== localName)
|
|
51
|
+
return null;
|
|
52
|
+
const id = attributeByLocalName(el, idAttr);
|
|
53
|
+
if (id === null || seen.has(id)) return null;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
entries.push({ id, el, xml: serializeXml(el) });
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
function referencedCommentIds(doc) {
|
|
60
|
+
const ids = /* @__PURE__ */ new Set();
|
|
61
|
+
for (const [id, comment] of commentReferencesIn(doc)) {
|
|
62
|
+
ids.add(id);
|
|
63
|
+
for (const reply of comment.replies) ids.add(reply.id);
|
|
64
|
+
}
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
function wellFormedEntry(entry) {
|
|
68
|
+
if (entry.namespaceURI === W_NS && entry.localName === "comment") {
|
|
69
|
+
return attributesWithin(entry, COMMENT_ATTRIBUTES) && readStrictCommentBody(entry) !== null;
|
|
70
|
+
}
|
|
71
|
+
if (entry.namespaceURI === W15_NS && entry.localName === "commentEx") {
|
|
72
|
+
return wellFormedCommentExtension(entry);
|
|
73
|
+
}
|
|
74
|
+
if (entry.namespaceURI === W15_NS && entry.localName === "person") {
|
|
75
|
+
return wellFormedPerson(entry);
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
function withoutThreadKey(entry) {
|
|
80
|
+
const copy = entry.cloneNode(true);
|
|
81
|
+
if (!isElement(copy)) return serializeXml(entry);
|
|
82
|
+
const last = lastBodyParagraph(copy);
|
|
83
|
+
last?.removeAttributeNS(W14_NS, "paraId");
|
|
84
|
+
return serializeXml(copy);
|
|
85
|
+
}
|
|
86
|
+
function threadKeyAlone(entry, original) {
|
|
87
|
+
const before = lastParagraphId(original);
|
|
88
|
+
const after = lastParagraphId(entry);
|
|
89
|
+
const kept = before === null || after === before;
|
|
90
|
+
return kept && withoutThreadKey(entry) === withoutThreadKey(original);
|
|
91
|
+
}
|
|
92
|
+
function entryAllowed(entry, original, authorId, editableComments, people) {
|
|
93
|
+
if (entry.namespaceURI === W_NS && entry.localName === "comment") {
|
|
94
|
+
const author = attributeByLocalName(entry, "author");
|
|
95
|
+
const recorded = author === null ? null : commentAuthorId(people, author);
|
|
96
|
+
if (original === null) return recorded === authorId;
|
|
97
|
+
const paraId = lastParagraphId(original);
|
|
98
|
+
if (!sameAttributes(entry, original, COMMENT_IDENTITY) || paraId !== null && lastParagraphId(entry) !== paraId) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return editableComments === "all" || recorded === null || recorded === authorId;
|
|
102
|
+
}
|
|
103
|
+
if (entry.namespaceURI === W15_NS && entry.localName === "commentEx") {
|
|
104
|
+
if (original === null) return true;
|
|
105
|
+
return sameAttributes(entry, original, ["paraId", "paraIdParent"]);
|
|
106
|
+
}
|
|
107
|
+
if (entry.namespaceURI === W15_NS && entry.localName === "person") {
|
|
108
|
+
return original === null && recordedIdentity(entry) === authorId;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
function commentEntries(story) {
|
|
113
|
+
return Array.from(
|
|
114
|
+
arrivedEntries(story.session.comments.xml, "comment", "id").values(),
|
|
115
|
+
(entry) => entry.el
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
var COMMENT_PARTS = [
|
|
119
|
+
{
|
|
120
|
+
namespace: W_NS,
|
|
121
|
+
localName: "comment",
|
|
122
|
+
idAttr: "id",
|
|
123
|
+
xmlOf: (story) => story.session.comments.xml,
|
|
124
|
+
pathOf: (story) => story.session.comments.partPath,
|
|
125
|
+
referents: (story) => referencedCommentIds(story.doc)
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
namespace: W15_NS,
|
|
129
|
+
localName: "commentEx",
|
|
130
|
+
idAttr: "paraId",
|
|
131
|
+
xmlOf: (story) => story.session.comments.extendedXml,
|
|
132
|
+
pathOf: (story) => story.session.comments.extendedPartPath,
|
|
133
|
+
// Thread state stands for a comment, and it is keyed by the comment's own thread key
|
|
134
|
+
referents: (story) => new Set(
|
|
135
|
+
commentEntries(story).flatMap((el) => {
|
|
136
|
+
const key = lastParagraphId(el);
|
|
137
|
+
return key === null ? [] : [key];
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
namespace: W15_NS,
|
|
143
|
+
localName: "person",
|
|
144
|
+
idAttr: "author",
|
|
145
|
+
xmlOf: (story) => story.session.comments.people.xml,
|
|
146
|
+
pathOf: (story) => story.session.comments.people.partPath,
|
|
147
|
+
// An identity stands for a name somebody writes comments under
|
|
148
|
+
referents: (story) => new Set(
|
|
149
|
+
commentEntries(story).flatMap((el) => {
|
|
150
|
+
const author = attributeByLocalName(el, "author");
|
|
151
|
+
return author === null ? [] : [author];
|
|
152
|
+
})
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
];
|
|
156
|
+
function partKept(kind, before, after, authorId, editableComments) {
|
|
157
|
+
const arrived = arrivedEntries(
|
|
158
|
+
kind.xmlOf(before),
|
|
159
|
+
kind.localName,
|
|
160
|
+
kind.idAttr
|
|
161
|
+
);
|
|
162
|
+
const submitted = submittedEntries(
|
|
163
|
+
kind.xmlOf(after),
|
|
164
|
+
kind.namespace,
|
|
165
|
+
kind.localName,
|
|
166
|
+
kind.idAttr
|
|
167
|
+
);
|
|
168
|
+
if (submitted === null) return false;
|
|
169
|
+
const stoodBehindNow = kind.referents(after);
|
|
170
|
+
const people = after.session.comments.people;
|
|
171
|
+
for (const entry of submitted) {
|
|
172
|
+
const original = arrived.get(entry.id);
|
|
173
|
+
if (original && original.xml === entry.xml) continue;
|
|
174
|
+
if (!stoodBehindNow.has(entry.id)) return false;
|
|
175
|
+
if (original && threadKeyAlone(entry.el, original.el)) continue;
|
|
176
|
+
if (!wellFormedEntry(entry.el) || !entryAllowed(
|
|
177
|
+
entry.el,
|
|
178
|
+
original?.el ?? null,
|
|
179
|
+
authorId,
|
|
180
|
+
editableComments,
|
|
181
|
+
people
|
|
182
|
+
)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const held = new Set(submitted.map((entry) => entry.id));
|
|
187
|
+
const stoodBehindBefore = kind.referents(before);
|
|
188
|
+
return Array.from(arrived.keys()).every(
|
|
189
|
+
(id) => stoodBehindBefore.has(id) || held.has(id)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
function commentPartsKept(before, after, authorId, editableComments) {
|
|
193
|
+
for (const kind of COMMENT_PARTS) {
|
|
194
|
+
const path = kind.pathOf(after) ?? kind.pathOf(before);
|
|
195
|
+
if (path === null) continue;
|
|
196
|
+
if (!partKept(kind, before, after, authorId, editableComments)) {
|
|
197
|
+
return { ok: false, reason: "part-changed", part: path };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { ok: true };
|
|
201
|
+
}
|
|
202
|
+
export {
|
|
203
|
+
commentPartsKept,
|
|
204
|
+
entryAllowed,
|
|
205
|
+
wellFormedEntry
|
|
206
|
+
};
|
|
@@ -7,5 +7,8 @@ import type { SessionStore } from "../session";
|
|
|
7
7
|
export interface CommentPartChanges {
|
|
8
8
|
parts: ReadonlyMap<string, Uint8Array>;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Plans the Comments part, relationship and content type only when comment state changed, and the
|
|
12
|
+
* people part beside them for an author whose identity the document has yet to record.
|
|
13
|
+
*/
|
|
11
14
|
export declare function planCommentParts(doc: PMNode, session: SessionStore, relationships: RelationshipWriter, currentContentTypes?: Uint8Array): CommentPartChanges | null;
|