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.
Files changed (33) hide show
  1. package/README.md +68 -0
  2. package/commentManager.js +16 -7
  3. package/ep.json +1 -0
  4. package/exportHTML.js +36 -8
  5. package/index.js +152 -17
  6. package/locales/en.json +5 -0
  7. package/package.json +3 -4
  8. package/static/css/comment.css +188 -2
  9. package/static/js/commentBoxes.js +31 -10
  10. package/static/js/copyPasteEvents.js +12 -13
  11. package/static/js/index.js +415 -32
  12. package/static/js/timeslider.js +367 -0
  13. package/static/tests/backend/specs/editPermissions.js +118 -0
  14. package/static/tests/backend/specs/readOnlyPad.js +38 -2
  15. package/static/tests/frontend-new/helper/comments.ts +8 -1
  16. package/static/tests/frontend-new/specs/addCommentButtonState.spec.ts +74 -0
  17. package/static/tests/frontend-new/specs/authorColor.spec.ts +58 -0
  18. package/static/tests/frontend-new/specs/commentNavigation.spec.ts +136 -0
  19. package/static/tests/frontend-new/specs/commentSuggestion.spec.ts +156 -2
  20. package/static/tests/frontend-new/specs/commentsOverview.spec.ts +63 -0
  21. package/static/tests/frontend-new/specs/crossPadCommentCopy.spec.ts +62 -0
  22. package/static/tests/frontend-new/specs/exportComments.spec.ts +65 -0
  23. package/static/tests/frontend-new/specs/floatingCommentButton.spec.ts +39 -0
  24. package/static/tests/frontend-new/specs/highContrast.spec.ts +65 -0
  25. package/static/tests/frontend-new/specs/mobilePopup.spec.ts +64 -0
  26. package/static/tests/frontend-new/specs/multipleCommentsPerLine.spec.ts +62 -0
  27. package/static/tests/frontend-new/specs/readonlyButton.spec.ts +28 -0
  28. package/static/tests/frontend-new/specs/readonlyCommenting.spec.ts +40 -0
  29. package/static/tests/frontend-new/specs/readonlyHideActions.spec.ts +80 -0
  30. package/static/tests/frontend-new/specs/timeslider.spec.ts +225 -0
  31. package/templates/commentBarButtons.ejs +2 -2
  32. package/templates/comments.html +5 -0
  33. 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 log4js = require('ep_etherpad-lite/node_modules/log4js');
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
- const logger = log4js.getLogger('ep_comments_page');
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 = _.mapObject(originalComments, (thisComment) => _.clone(thisComment));
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 = _.mapObject(originalReplies, (thisReply) => _.clone(thisReply));
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
- div.append(
52
- $('<p>')
53
- .attr('role', 'comment')
54
- .addClass('comment')
55
- .attr('id', commentId)
56
- .text(`* ${comment.text}`));
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
- exports.eejsBlock_mySettings = commentsToggle.eejsBlock_mySettings;
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
- await commentManager.deleteComment(padId, data.commentId, data.authorId);
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
- return _.object(commentIds, comments); // {c-123:data, c-124:data}
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 _.zip(repliesId, replies);
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, authorId} = data;
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
- class: 'buttonicon buttonicon-comment-medical',
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
- exports.eejsBlock_editbarMenuLeft = (hookName, args, cb) => {
186
- // check if custom button is used
187
- if (JSON.stringify(settings.toolbar).indexOf('addComment') > -1) {
188
- return cb();
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({displayCommentAsIcon, highlightSelectedText}, helperVars);
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.14",
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.0",
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",