ep_comments_page 11.1.14 → 11.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/commentManager.js +16 -7
- package/ep.json +1 -0
- package/exportHTML.js +36 -8
- package/index.js +152 -17
- package/locales/en.json +5 -0
- package/package.json +3 -4
- package/static/css/comment.css +188 -2
- package/static/js/commentBoxes.js +31 -10
- package/static/js/copyPasteEvents.js +12 -13
- package/static/js/index.js +415 -32
- package/static/js/timeslider.js +367 -0
- package/static/tests/backend/specs/editPermissions.js +118 -0
- package/static/tests/backend/specs/readOnlyPad.js +38 -2
- package/static/tests/frontend-new/helper/comments.ts +8 -1
- package/static/tests/frontend-new/specs/addCommentButtonState.spec.ts +74 -0
- package/static/tests/frontend-new/specs/authorColor.spec.ts +58 -0
- package/static/tests/frontend-new/specs/commentNavigation.spec.ts +136 -0
- package/static/tests/frontend-new/specs/commentSuggestion.spec.ts +156 -2
- package/static/tests/frontend-new/specs/commentsOverview.spec.ts +63 -0
- package/static/tests/frontend-new/specs/crossPadCommentCopy.spec.ts +62 -0
- package/static/tests/frontend-new/specs/exportComments.spec.ts +65 -0
- package/static/tests/frontend-new/specs/floatingCommentButton.spec.ts +39 -0
- package/static/tests/frontend-new/specs/highContrast.spec.ts +65 -0
- package/static/tests/frontend-new/specs/mobilePopup.spec.ts +64 -0
- package/static/tests/frontend-new/specs/multipleCommentsPerLine.spec.ts +62 -0
- package/static/tests/frontend-new/specs/readonlyButton.spec.ts +28 -0
- package/static/tests/frontend-new/specs/readonlyCommenting.spec.ts +40 -0
- package/static/tests/frontend-new/specs/readonlyHideActions.spec.ts +80 -0
- package/static/tests/frontend-new/specs/timeslider.spec.ts +225 -0
- package/templates/commentBarButtons.ejs +2 -2
- package/templates/comments.html +5 -0
- package/templates/commentsOverviewSetting.ejs +5 -0
package/README.md
CHANGED
|
@@ -10,6 +10,21 @@
|
|
|
10
10
|
pnpm run plugins install ep_comments_page
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## Comments in the timeslider
|
|
14
|
+
|
|
15
|
+
Comments are shown **read-only** in the timeslider and in-place pad history.
|
|
16
|
+
Each comment lines up with the text it annotates and only appears for the
|
|
17
|
+
revisions where that text exists, so you can read the discussion as you scrub
|
|
18
|
+
through history. Suggested changes are shown too, with the original text struck
|
|
19
|
+
through once the change has been accepted. Toggling **Show Comments** off hides
|
|
20
|
+
the comments — both the sidebar and the inline highlight — in history as well.
|
|
21
|
+
|
|
22
|
+
This works on the latest Etherpad release as well as develop. Older timeslider
|
|
23
|
+
bundles can't load plugin client hooks, so the read-only viewer is injected as a
|
|
24
|
+
plain script and reconstructs the comment positions from the server — no core
|
|
25
|
+
update required. (Reading comments while scrubbing *in-place* history on the pad
|
|
26
|
+
URL additionally needs develop's in-place history mode.)
|
|
27
|
+
|
|
13
28
|
## Extra settings
|
|
14
29
|
This plugin has some extra features that can be enabled by changing values on `settings.json` of your Etherpad instance.
|
|
15
30
|
|
|
@@ -40,6 +55,59 @@ To enable this feature, add the following code to your `settings.json`:
|
|
|
40
55
|
|
|
41
56
|
**Warning**: there is a side effect when you enable this feature: a revision is created everytime the text is highlighted, resulting on apparently "empty" changes when you check your pad on the timeslider. If that is an issue for you, we don't recommend you to use this feature.
|
|
42
57
|
|
|
58
|
+
### Who can edit or delete comments
|
|
59
|
+
By default a comment can only be edited or deleted by the author who created it
|
|
60
|
+
(verified server-side from the author's Etherpad session, so it can't be
|
|
61
|
+
spoofed). To instead let **anyone with write access to the pad** edit or delete
|
|
62
|
+
any comment, add the following to your `settings.json`:
|
|
63
|
+
```
|
|
64
|
+
"ep_comments_page": {
|
|
65
|
+
"allowAnyoneToEditComments": true
|
|
66
|
+
},
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Let read-only viewers comment
|
|
70
|
+
By default a read-only viewer cannot add comments (the add-comment button is
|
|
71
|
+
hidden and the server rejects comment changes from a read-only session). To let
|
|
72
|
+
read-only viewers annotate a pad without being able to edit its text, enable:
|
|
73
|
+
```
|
|
74
|
+
"ep_comments_page": {
|
|
75
|
+
"allowReadonlyComments": true
|
|
76
|
+
},
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Comments in exported documents
|
|
80
|
+
Comments are included when you export a pad. Each commented passage gets a
|
|
81
|
+
numbered footnote marker (`[1]`, `[2]`, …) and a **Comments** section is appended
|
|
82
|
+
listing each comment as `[n] author: text` (suggested changes are noted too).
|
|
83
|
+
Because the markers are numbered they stay correlatable in rich formats such as
|
|
84
|
+
**ODT, DOC, DOCX and PDF**, where the in-document anchor link is lost — so your
|
|
85
|
+
comments are not silently dropped when you export. Plain-text (`.txt`) export
|
|
86
|
+
omits comments, as the core text exporter has no extension point for them.
|
|
87
|
+
|
|
88
|
+
### Author colour accent
|
|
89
|
+
Each comment box shows a left-border accent in its author's colour. This is on
|
|
90
|
+
by default; disable it with:
|
|
91
|
+
```
|
|
92
|
+
"ep_comments_page": {
|
|
93
|
+
"showAuthorColor": false
|
|
94
|
+
},
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Floating comment button
|
|
98
|
+
When text is selected, a small floating button appears next to it for quickly
|
|
99
|
+
adding a comment. This is on by default; disable it with:
|
|
100
|
+
```
|
|
101
|
+
"ep_comments_page": {
|
|
102
|
+
"floatingCommentButton": false
|
|
103
|
+
},
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### All-comments overview
|
|
107
|
+
The **Settings** pane has a "Show all comments" checkbox. Tick it to open a panel
|
|
108
|
+
listing every comment in the pad; clicking an entry jumps to that comment, and it
|
|
109
|
+
updates as comments are added or removed. The choice is remembered per pad.
|
|
110
|
+
|
|
43
111
|
### Disable HTML export
|
|
44
112
|
By default comments are exported to HTML, but if you don't wish to do that then you can disable it by adding the following to your `settings.json`:
|
|
45
113
|
```
|
package/commentManager.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const _ = require('underscore');
|
|
4
3
|
const db = require('ep_etherpad-lite/node/db/DB');
|
|
5
|
-
const
|
|
4
|
+
const settings = require('ep_etherpad-lite/node/utils/Settings');
|
|
5
|
+
const {createLogger} = require('ep_plugin_helpers/logger');
|
|
6
6
|
const randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString;
|
|
7
7
|
const shared = require('./static/js/shared');
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// #222: by default a comment may only be edited/deleted by its original author
|
|
10
|
+
// (the restrictive behaviour introduced in #163). Setting
|
|
11
|
+
// `ep_comments_page.allowAnyoneToEditComments: true` switches to the permissive
|
|
12
|
+
// model where anyone with write access to the pad may edit/delete any comment.
|
|
13
|
+
const anyoneMayEditComments = () =>
|
|
14
|
+
!!(settings.ep_comments_page && settings.ep_comments_page.allowAnyoneToEditComments);
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('ep_comments_page');
|
|
10
17
|
|
|
11
18
|
exports.getComments = async (padId) => {
|
|
12
19
|
// Not sure if we will encouter race conditions here.. Be careful.
|
|
@@ -23,7 +30,7 @@ exports.deleteComment = async (padId, commentId, authorId) => {
|
|
|
23
30
|
logger.debug(`ignoring attempt to delete non-existent comment ${commentId}`);
|
|
24
31
|
throw new Error('no_such_comment');
|
|
25
32
|
}
|
|
26
|
-
if (comments[commentId].author !== authorId) {
|
|
33
|
+
if (!anyoneMayEditComments() && comments[commentId].author !== authorId) {
|
|
27
34
|
logger.debug(`author ${authorId} attempted to delete comment ${commentId} ` +
|
|
28
35
|
`belonging to author ${comments[commentId].author}`);
|
|
29
36
|
throw new Error('unauth');
|
|
@@ -78,7 +85,8 @@ exports.copyComments = async (originalPadId, newPadID) => {
|
|
|
78
85
|
// get the comments of original pad
|
|
79
86
|
const originalComments = await db.get(`comments:${originalPadId}`);
|
|
80
87
|
// make sure we have different copies of the comment between pads
|
|
81
|
-
const copiedComments =
|
|
88
|
+
const copiedComments = Object.fromEntries(
|
|
89
|
+
Object.entries(originalComments || {}).map(([id, comment]) => [id, {...comment}]));
|
|
82
90
|
|
|
83
91
|
// save the comments on new pad
|
|
84
92
|
await db.set(`comments:${newPadID}`, copiedComments);
|
|
@@ -141,7 +149,8 @@ exports.copyCommentReplies = async (originalPadId, newPadID) => {
|
|
|
141
149
|
// get the replies of original pad
|
|
142
150
|
const originalReplies = await db.get(`comment-replies:${originalPadId}`);
|
|
143
151
|
// make sure we have different copies of the reply between pads
|
|
144
|
-
const copiedReplies =
|
|
152
|
+
const copiedReplies = Object.fromEntries(
|
|
153
|
+
Object.entries(originalReplies || {}).map(([id, reply]) => [id, {...reply}]));
|
|
145
154
|
|
|
146
155
|
// save the comment replies on new pad
|
|
147
156
|
await db.set(`comment-replies:${newPadID}`, copiedReplies);
|
|
@@ -196,7 +205,7 @@ exports.changeCommentText = async (padId, commentId, commentText, authorId) => {
|
|
|
196
205
|
logger.debug(`ignoring attempt to edit non-existent comment ${commentId}`);
|
|
197
206
|
throw new Error('no_such_comment');
|
|
198
207
|
}
|
|
199
|
-
if (comments[commentId].author !== authorId) {
|
|
208
|
+
if (!anyoneMayEditComments() && comments[commentId].author !== authorId) {
|
|
200
209
|
logger.debug(`author ${authorId} attempted to edit comment ${commentId} ` +
|
|
201
210
|
`belonging to author ${comments[commentId].author}`);
|
|
202
211
|
throw new Error('unauth');
|
package/ep.json
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"eejsBlock_mySettings": "ep_comments_page/index",
|
|
27
27
|
"eejsBlock_padSettings": "ep_comments_page/index",
|
|
28
28
|
"eejsBlock_styles": "ep_comments_page/index",
|
|
29
|
+
"eejsBlock_timesliderScripts": "ep_comments_page/index",
|
|
29
30
|
"loadSettings": "ep_comments_page/index",
|
|
30
31
|
"clientVars": "ep_comments_page/index",
|
|
31
32
|
"exportHtmlAdditionalTagsWithData": "ep_comments_page/exportHTML",
|
package/exportHTML.js
CHANGED
|
@@ -15,11 +15,27 @@ const findAllCommentUsedOn = (pad) => {
|
|
|
15
15
|
exports.exportHtmlAdditionalTagsWithData =
|
|
16
16
|
async (hookName, pad) => findAllCommentUsedOn(pad).map((name) => ['comment', name]);
|
|
17
17
|
|
|
18
|
+
// Footnote-style markers for comments. Numbering them (rather than a bare "*")
|
|
19
|
+
// keeps the inline reference correlatable with the comment list once the export
|
|
20
|
+
// is flattened to a rich format (odt/doc/pdf) where the `#id` anchor is lost.
|
|
21
|
+
// Numbering follows the comments map order so both hooks agree (#190).
|
|
22
|
+
// Build a commentId -> "[n]" lookup once so per-span/per-comment marker
|
|
23
|
+
// resolution is O(1) instead of an indexOf scan per call (avoids O(n^2) over
|
|
24
|
+
// many comments, #190).
|
|
25
|
+
const buildMarkerMap = (commentIds) => {
|
|
26
|
+
const markers = new Map();
|
|
27
|
+
commentIds.forEach((id, i) => markers.set(id, `[${i + 1}]`));
|
|
28
|
+
return markers;
|
|
29
|
+
};
|
|
30
|
+
|
|
18
31
|
exports.getLineHTMLForExport = async (hookName, context) => {
|
|
19
32
|
if (settings.ep_comments_page && settings.ep_comments_page.exportHtml === false) return;
|
|
20
33
|
|
|
21
34
|
// I'm not sure how optimal this is - it will do a database lookup for each line..
|
|
22
35
|
const {comments} = await commentManager.getComments(context.padId);
|
|
36
|
+
if (!comments) return;
|
|
37
|
+
const commentIds = Object.keys(comments);
|
|
38
|
+
const markers = buildMarkerMap(commentIds);
|
|
23
39
|
let hasPlugin = false;
|
|
24
40
|
// Load the HTML into a throwaway div instead of calling $.load() to avoid
|
|
25
41
|
// https://github.com/cheeriojs/cheerio/issues/1031
|
|
@@ -33,7 +49,7 @@ exports.getLineHTMLForExport = async (hookName, context) => {
|
|
|
33
49
|
hasPlugin = true;
|
|
34
50
|
span.append(
|
|
35
51
|
$('<sup>').append(
|
|
36
|
-
$('<a>').attr('href', `#${commentId}`).text(
|
|
52
|
+
$('<a>').attr('href', `#${commentId}`).text(markers.get(commentId))));
|
|
37
53
|
// Replace data-comment="foo" with class="comment foo".
|
|
38
54
|
if (/^c-[0-9a-zA-Z]+$/.test(commentId)) {
|
|
39
55
|
span.removeAttr('data-comment').addClass('comment').addClass(commentId);
|
|
@@ -45,15 +61,27 @@ exports.getLineHTMLForExport = async (hookName, context) => {
|
|
|
45
61
|
exports.exportHTMLAdditionalContent = async (hookName, {padId}) => {
|
|
46
62
|
if (settings.ep_comments_page && settings.ep_comments_page.exportHtml === false) return;
|
|
47
63
|
const {comments} = await commentManager.getComments(padId);
|
|
48
|
-
if (!comments) return;
|
|
64
|
+
if (!comments || !Object.keys(comments).length) return;
|
|
65
|
+
const commentIds = Object.keys(comments);
|
|
66
|
+
const markers = buildMarkerMap(commentIds);
|
|
49
67
|
const div = $('<div>').attr('id', 'comments');
|
|
68
|
+
// A heading so the block is recognisable once flattened into odt/doc/pdf,
|
|
69
|
+
// where the surrounding markup is gone.
|
|
70
|
+
div.append($('<h2>').text('Comments'));
|
|
50
71
|
for (const [commentId, comment] of Object.entries(comments)) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
// Build "[n] author: text" with an optional suggested-change clause so the
|
|
73
|
+
// full comment survives into rich-format exports (#190). Assemble via text
|
|
74
|
+
// nodes so author/comment content can't inject markup.
|
|
75
|
+
const p = $('<p>')
|
|
76
|
+
.attr('role', 'comment')
|
|
77
|
+
.addClass('comment')
|
|
78
|
+
.attr('id', commentId);
|
|
79
|
+
const marker = markers.get(commentId);
|
|
80
|
+
const author = (comment.name && String(comment.name).trim()) || 'Anonymous';
|
|
81
|
+
let line = `${marker} ${author}: ${comment.text || ''}`;
|
|
82
|
+
if (comment.changeTo) line += ` (suggested change to: "${comment.changeTo}")`;
|
|
83
|
+
p.text(line);
|
|
84
|
+
div.append(p);
|
|
57
85
|
}
|
|
58
86
|
// adds additional HTML to the body, we get this HTML from the database of comments:padId
|
|
59
87
|
return $.html(div);
|
package/index.js
CHANGED
|
@@ -4,15 +4,83 @@ const {template} = require('ep_plugin_helpers');
|
|
|
4
4
|
|
|
5
5
|
const AttributePool = require('ep_etherpad-lite/static/js/AttributePool').default || require('ep_etherpad-lite/static/js/AttributePool');
|
|
6
6
|
const Changeset = require('ep_etherpad-lite/static/js/Changeset').default || require('ep_etherpad-lite/static/js/Changeset');
|
|
7
|
-
const eejs = require('ep_etherpad-lite/node/eejs
|
|
7
|
+
const eejs = require('ep_etherpad-lite/node/eejs');
|
|
8
8
|
const settings = require('ep_etherpad-lite/node/utils/Settings');
|
|
9
9
|
const {Formidable} = require('formidable');
|
|
10
10
|
const commentManager = require('./commentManager');
|
|
11
11
|
const apiUtils = require('./apiUtils');
|
|
12
|
-
const _ = require('underscore');
|
|
13
12
|
const padMessageHandler = require('ep_etherpad-lite/node/handler/PadMessageHandler');
|
|
14
13
|
const readOnlyManager = require('ep_etherpad-lite/node/db/ReadOnlyManager').default || require('ep_etherpad-lite/node/db/ReadOnlyManager');
|
|
14
|
+
const padManager = require('ep_etherpad-lite/node/db/PadManager');
|
|
15
|
+
const authorManager = require('ep_etherpad-lite/node/db/AuthorManager').default || require('ep_etherpad-lite/node/db/AuthorManager');
|
|
16
|
+
|
|
17
|
+
// Resolve the authoritative authorId for a /comment socket connection from the
|
|
18
|
+
// HttpOnly author-token cookie on its handshake — the same cookie core uses to
|
|
19
|
+
// identify the author. The cookie is never exposed to the page, so a client
|
|
20
|
+
// cannot spoof another user's authorId (#222). Returns null when it can't be
|
|
21
|
+
// resolved (e.g. no token cookie), in which case authorship checks fail closed.
|
|
22
|
+
// Mirrors core's PadMessageHandler cookie parsing (the socket.io handshake does
|
|
23
|
+
// not run cookie-parser, so read the Cookie header directly).
|
|
24
|
+
const authorIdForSocket = async (socket) => {
|
|
25
|
+
try {
|
|
26
|
+
const cookiePrefix = (settings.cookie && settings.cookie.prefix) || '';
|
|
27
|
+
const cookieHeader =
|
|
28
|
+
(socket && socket.request && socket.request.headers && socket.request.headers.cookie) || '';
|
|
29
|
+
const match = cookieHeader.split(/;\s*/).find(
|
|
30
|
+
(c) => c.split('=')[0] === `${cookiePrefix}token`);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
let token;
|
|
33
|
+
try {
|
|
34
|
+
token = decodeURIComponent(match.split('=').slice(1).join('='));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err instanceof URIError) return null; // malformed cookie -> treat as absent
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
if (!token) return null;
|
|
40
|
+
const getAuthorId = authorManager.getAuthorId
|
|
41
|
+
? (t) => authorManager.getAuthorId(t, {})
|
|
42
|
+
: (t) => authorManager.getAuthor4Token(t); // older cores
|
|
43
|
+
return await getAuthorId(token);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
// Exported for tests (verifies author identity derives from the token cookie).
|
|
49
|
+
exports.authorIdForSocket = authorIdForSocket;
|
|
50
|
+
|
|
51
|
+
// Comment char-ranges per line for a given revision's atext. The timeslider on
|
|
52
|
+
// older Etherpad cores can't run the plugin's client hooks, so it never paints
|
|
53
|
+
// the `comment` class; this lets the client reconstruct those ranges and render
|
|
54
|
+
// comments read-only there (issue #33). Returns {commentId: [{line, start, end}]}.
|
|
55
|
+
const commentLocationsFromAText = (atext, apool) => {
|
|
56
|
+
const text = atext.text;
|
|
57
|
+
const out = {};
|
|
58
|
+
let charIdx = 0;
|
|
59
|
+
let line = 0;
|
|
60
|
+
let col = 0;
|
|
61
|
+
const opIter = Changeset.opIterator(atext.attribs);
|
|
62
|
+
while (opIter.hasNext()) {
|
|
63
|
+
const op = opIter.next();
|
|
64
|
+
let commentId = null;
|
|
65
|
+
Changeset.eachAttribNumber(op.attribs, (n) => {
|
|
66
|
+
if (apool.getAttribKey(n) === 'comment') commentId = apool.getAttribValue(n);
|
|
67
|
+
});
|
|
68
|
+
for (let i = 0; i < op.chars; i++) {
|
|
69
|
+
const ch = text[charIdx++];
|
|
70
|
+
if (ch === '\n') { line++; col = 0; continue; }
|
|
71
|
+
if (commentId) {
|
|
72
|
+
const ranges = out[commentId] || (out[commentId] = []);
|
|
73
|
+
const last = ranges[ranges.length - 1];
|
|
74
|
+
if (last && last.line === line && last.end === col) last.end = col + 1;
|
|
75
|
+
else ranges.push({line, start: col, end: col + 1});
|
|
76
|
+
}
|
|
77
|
+
col++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
15
82
|
const {padToggle} = require('ep_plugin_helpers/pad-toggle-server');
|
|
83
|
+
const {toggle} = require('ep_plugin_helpers/settings-toggle');
|
|
16
84
|
|
|
17
85
|
// Parallel User Settings + Pad Wide Settings checkboxes for comment-pane
|
|
18
86
|
// visibility. Helper owns the storage, broadcast, enforce, and i18n wiring.
|
|
@@ -24,8 +92,22 @@ const commentsToggle = padToggle({
|
|
|
24
92
|
defaultEnabled: true,
|
|
25
93
|
});
|
|
26
94
|
|
|
95
|
+
// #12/#5: the all-comments overview is a checkbox in the user Settings pane
|
|
96
|
+
// (not a toolbar icon), built with the ep_plugin_helpers `toggle` helper —
|
|
97
|
+
// cookie-persisted, default off. The client shows/hides the panel from it.
|
|
98
|
+
const overviewToggle = toggle({
|
|
99
|
+
pluginName: 'ep_comments_page',
|
|
100
|
+
settingId: 'comments-overview',
|
|
101
|
+
templatePath: 'ep_comments_page/templates/commentsOverviewSetting.ejs',
|
|
102
|
+
defaultEnabled: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
27
105
|
exports.loadSettings = commentsToggle.loadSettings;
|
|
28
|
-
|
|
106
|
+
// Compose both settings checkboxes (Show Comments + Show all comments) into the
|
|
107
|
+
// single eejsBlock_mySettings hook.
|
|
108
|
+
exports.eejsBlock_mySettings = (hookName, args, cb) =>
|
|
109
|
+
commentsToggle.eejsBlock_mySettings(hookName, args, () =>
|
|
110
|
+
overviewToggle.eejsBlock_mySettings(hookName, args, cb));
|
|
29
111
|
exports.eejsBlock_padSettings = commentsToggle.eejsBlock_padSettings;
|
|
30
112
|
|
|
31
113
|
let io;
|
|
@@ -55,6 +137,10 @@ exports.handleMessageSecurity = async (hookName, ctx) => {
|
|
|
55
137
|
if (dtype !== 'USER_CHANGES') return;
|
|
56
138
|
// Nothing needs to be done if the user already has write access.
|
|
57
139
|
if (!padMessageHandler.sessioninfos[socket.id].readonly) return;
|
|
140
|
+
// Read-only commenting is opt-in (#8). When it's off (the default), fall
|
|
141
|
+
// through without granting permission so core's normal read-only enforcement
|
|
142
|
+
// rejects the change.
|
|
143
|
+
if (!(settings.ep_comments_page && settings.ep_comments_page.allowReadonlyComments)) return;
|
|
58
144
|
const pool = new AttributePool().fromJsonable(apool);
|
|
59
145
|
const cs = Changeset.unpack(changeset);
|
|
60
146
|
const opIter = Changeset.opIterator(cs.ops);
|
|
@@ -100,10 +186,26 @@ exports.socketio = (hookName, args, cb) => {
|
|
|
100
186
|
return await commentManager.getCommentReplies(padId);
|
|
101
187
|
}));
|
|
102
188
|
|
|
189
|
+
// Where each comment's text sits at a given revision (for the timeslider).
|
|
190
|
+
socket.on('getCommentLocations', handler(async (data) => {
|
|
191
|
+
const {padId} = await readOnlyManager.getIds(data.padId);
|
|
192
|
+
const pad = await padManager.getPad(padId);
|
|
193
|
+
const head = pad.getHeadRevisionNumber();
|
|
194
|
+
let rev = Number(data.rev);
|
|
195
|
+
if (!Number.isInteger(rev) || rev < 0 || rev > head) rev = head;
|
|
196
|
+
const atext = await pad.getInternalRevisionAText(rev);
|
|
197
|
+
return {rev, locations: commentLocationsFromAText(atext, pad.pool)};
|
|
198
|
+
}));
|
|
199
|
+
|
|
103
200
|
// On add events
|
|
104
201
|
socket.on('addComment', handler(async (data) => {
|
|
105
202
|
const {padId} = await readOnlyManager.getIds(data.padId);
|
|
106
203
|
const content = data.comment;
|
|
204
|
+
// Stamp the authoritative author server-side so a comment can't be created
|
|
205
|
+
// labelled as someone else (#222). Fall back to the supplied value when no
|
|
206
|
+
// token is resolvable (e.g. API/test contexts without the cookie).
|
|
207
|
+
const resolvedAuthor = await authorIdForSocket(socket);
|
|
208
|
+
if (content && resolvedAuthor) content.author = resolvedAuthor;
|
|
107
209
|
const [commentId, comment] = await commentManager.addComment(padId, content);
|
|
108
210
|
if (commentId != null && comment != null) {
|
|
109
211
|
socket.broadcast.to(padId).emit('pushAddComment', commentId, comment);
|
|
@@ -113,7 +215,10 @@ exports.socketio = (hookName, args, cb) => {
|
|
|
113
215
|
|
|
114
216
|
socket.on('deleteComment', handler(async (data) => {
|
|
115
217
|
const {padId} = await readOnlyManager.getIds(data.padId);
|
|
116
|
-
|
|
218
|
+
// Authorize against the server-resolved author, never the client-supplied
|
|
219
|
+
// authorId (which is spoofable) (#222).
|
|
220
|
+
const authorId = await authorIdForSocket(socket);
|
|
221
|
+
await commentManager.deleteComment(padId, data.commentId, authorId);
|
|
117
222
|
socket.broadcast.to(padId).emit('commentDeleted', data.commentId);
|
|
118
223
|
}));
|
|
119
224
|
|
|
@@ -137,25 +242,33 @@ exports.socketio = (hookName, args, cb) => {
|
|
|
137
242
|
padId = (await readOnlyManager.getIds(padId)).padId;
|
|
138
243
|
const [commentIds, comments] = await commentManager.bulkAddComments(padId, data);
|
|
139
244
|
socket.broadcast.to(padId).emit('pushAddCommentInBulk');
|
|
140
|
-
|
|
245
|
+
// {c-123:data, c-124:data}
|
|
246
|
+
return Object.fromEntries(commentIds.map((id, i) => [id, comments[i]]));
|
|
141
247
|
}));
|
|
142
248
|
|
|
143
249
|
socket.on('bulkAddCommentReplies', handler(async (padId, data) => {
|
|
144
250
|
padId = (await readOnlyManager.getIds(padId)).padId;
|
|
145
251
|
const [repliesId, replies] = await commentManager.bulkAddCommentReplies(padId, data);
|
|
146
252
|
socket.broadcast.to(padId).emit('pushAddCommentReply', repliesId, replies);
|
|
147
|
-
return
|
|
253
|
+
return repliesId.map((id, i) => [id, replies[i]]);
|
|
148
254
|
}));
|
|
149
255
|
|
|
150
256
|
socket.on('updateCommentText', handler(async (data) => {
|
|
151
|
-
const {commentId, commentText
|
|
257
|
+
const {commentId, commentText} = data;
|
|
152
258
|
const {padId} = await readOnlyManager.getIds(data.padId);
|
|
259
|
+
// Authorize against the server-resolved author, never the client-supplied
|
|
260
|
+
// authorId (which is spoofable) (#222).
|
|
261
|
+
const authorId = await authorIdForSocket(socket);
|
|
153
262
|
await commentManager.changeCommentText(padId, commentId, commentText, authorId);
|
|
154
263
|
socket.broadcast.to(padId).emit('textCommentUpdated', commentId, commentText);
|
|
155
264
|
}));
|
|
156
265
|
|
|
157
266
|
socket.on('addCommentReply', handler(async (data) => {
|
|
158
267
|
const {padId} = await readOnlyManager.getIds(data.padId);
|
|
268
|
+
// Stamp the authoritative author server-side (#222); fall back to the
|
|
269
|
+
// supplied value when no token is resolvable (API/test contexts).
|
|
270
|
+
const resolvedAuthor = await authorIdForSocket(socket);
|
|
271
|
+
if (data && resolvedAuthor) data.author = resolvedAuthor;
|
|
159
272
|
const [replyId, reply] = await commentManager.addCommentReply(padId, data);
|
|
160
273
|
reply.replyId = replyId;
|
|
161
274
|
socket.broadcast.to(padId).emit('pushAddCommentReply', replyId, reply);
|
|
@@ -174,7 +287,9 @@ exports.padInitToolbar = (hookName, args, cb) => {
|
|
|
174
287
|
const button = toolbar.button({
|
|
175
288
|
command: 'addComment',
|
|
176
289
|
localizationId: 'ep_comments_page.add_comment.title',
|
|
177
|
-
|
|
290
|
+
// `acl-write` lets Etherpad core hide the button on read-only pads
|
|
291
|
+
// (`.readonly .acl-write { display: none }`) — see issue #204.
|
|
292
|
+
class: 'buttonicon buttonicon-comment-medical acl-write',
|
|
178
293
|
});
|
|
179
294
|
|
|
180
295
|
toolbar.registerButton('addComment', button);
|
|
@@ -182,14 +297,11 @@ exports.padInitToolbar = (hookName, args, cb) => {
|
|
|
182
297
|
return cb();
|
|
183
298
|
};
|
|
184
299
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
args.content += eejs.require('ep_comments_page/templates/commentBarButtons.ejs');
|
|
191
|
-
return cb();
|
|
192
|
-
};
|
|
300
|
+
// Skip the default toolbar button when the admin placed `addComment` in a
|
|
301
|
+
// custom toolbar layout. Uses the ep_plugin_helpers template() helper.
|
|
302
|
+
exports.eejsBlock_editbarMenuLeft = template('ep_comments_page/templates/commentBarButtons.ejs', {
|
|
303
|
+
skip: () => JSON.stringify(settings.toolbar).indexOf('addComment') > -1,
|
|
304
|
+
});
|
|
193
305
|
|
|
194
306
|
exports.eejsBlock_scripts = (hookName, args, cb) => {
|
|
195
307
|
args.content += eejs.require('ep_comments_page/templates/comments.html');
|
|
@@ -200,15 +312,38 @@ exports.eejsBlock_scripts = (hookName, args, cb) => {
|
|
|
200
312
|
exports.eejsBlock_styles =
|
|
201
313
|
template('ep_comments_page/templates/styles.html');
|
|
202
314
|
|
|
315
|
+
// Read-only comments in the timeslider (issue #33). Injected as plain scripts
|
|
316
|
+
// rather than a client hook because older timeslider bundles can't load plugin
|
|
317
|
+
// hooks. socket.io's served client is loaded first so the script has a global
|
|
318
|
+
// `io`. Relative paths resolve from /p/<pad>/timeslider to the site root.
|
|
319
|
+
exports.eejsBlock_timesliderScripts = (hookName, args, cb) => {
|
|
320
|
+
args.content +=
|
|
321
|
+
'<script src="../../socket.io/socket.io.js"></script>' +
|
|
322
|
+
'<script src="../../static/plugins/ep_comments_page/static/js/timeslider.js"></script>';
|
|
323
|
+
return cb();
|
|
324
|
+
};
|
|
325
|
+
|
|
203
326
|
exports.clientVars = async (hook, context) => {
|
|
204
327
|
const displayCommentAsIcon =
|
|
205
328
|
settings.ep_comments_page ? settings.ep_comments_page.displayCommentAsIcon : false;
|
|
206
329
|
const highlightSelectedText =
|
|
207
330
|
settings.ep_comments_page ? settings.ep_comments_page.highlightSelectedText : false;
|
|
331
|
+
// #95: the floating add-comment button is on unless an admin disables it.
|
|
332
|
+
const floatingCommentButton = !(settings.ep_comments_page &&
|
|
333
|
+
settings.ep_comments_page.floatingCommentButton === false);
|
|
334
|
+
// #6: author-colour accent is on unless an admin disables it.
|
|
335
|
+
const showAuthorColor = !(settings.ep_comments_page &&
|
|
336
|
+
settings.ep_comments_page.showAuthorColor === false);
|
|
337
|
+
// #8: read-only viewers may comment only when an admin opts in (default off).
|
|
338
|
+
const allowReadonlyComments =
|
|
339
|
+
!!(settings.ep_comments_page && settings.ep_comments_page.allowReadonlyComments);
|
|
208
340
|
// Merge in the padToggle helper's clientVars block so the client-side
|
|
209
341
|
// helper can read padWideSupported/initialPadEnabled/etc.
|
|
210
342
|
const helperVars = await commentsToggle.clientVars(hook, context);
|
|
211
|
-
return Object.assign(
|
|
343
|
+
return Object.assign(
|
|
344
|
+
{displayCommentAsIcon, highlightSelectedText, floatingCommentButton, showAuthorColor,
|
|
345
|
+
allowReadonlyComments},
|
|
346
|
+
helperVars);
|
|
212
347
|
};
|
|
213
348
|
|
|
214
349
|
exports.expressCreateServer = (hookName, args, callback) => {
|
package/locales/en.json
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
"ep_comments_page.add_comment.title" : "Add new comment on selection",
|
|
5
5
|
"ep_comments_page.add_comment" : "Add new comment on selection",
|
|
6
6
|
"ep_comments_page.add_comment.hint" : "Please first select the text to comment",
|
|
7
|
+
"ep_comments_page.comments_overview.title" : "Show all comments",
|
|
8
|
+
"ep_comments_page.comments_overview.header" : "All comments",
|
|
9
|
+
"ep_comments_page.comments_overview.empty" : "No comments yet",
|
|
7
10
|
"ep_comments_page.delete_comment.title" : "Delete this comment",
|
|
8
11
|
"ep_comments_page.edit_comment.title" : "Edit this comment",
|
|
12
|
+
"ep_comments_page.comment_nav.prev" : "Previous comment",
|
|
13
|
+
"ep_comments_page.comment_nav.next" : "Next comment",
|
|
9
14
|
"ep_comments_page.show_comments" : "Show Comments",
|
|
10
15
|
"ep_comments_page.comments_template.suggested_change" : "Suggested Change",
|
|
11
16
|
"ep_comments_page.comments_template.from" : "From",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"description": "Adds comments on sidebar and link it to the text. For no-skin use ep_page_view.",
|
|
3
3
|
"name": "ep_comments_page",
|
|
4
|
-
"version": "11.1.
|
|
4
|
+
"version": "11.1.17",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Nicolas Lescop",
|
|
7
7
|
"email": "limplementeur@gmail.com"
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"cheerio": "^1.2.0",
|
|
26
|
-
"ep_plugin_helpers": "^0.6.
|
|
27
|
-
"formidable": "^3.5.4"
|
|
28
|
-
"underscore": "^1.13.8"
|
|
26
|
+
"ep_plugin_helpers": "^0.6.7",
|
|
27
|
+
"formidable": "^3.5.4"
|
|
29
28
|
},
|
|
30
29
|
"devDependencies": {
|
|
31
30
|
"eslint": "^8.57.1",
|