Commit graph

9655 commits

Author SHA1 Message Date
SamTV12345
2a0bb2c62b build(test): wire pnpm test to vitest, drop redundant test:vitest CI step
src/package.json:
- test: was mocha --import=tsx --recursive ...; now `vitest run` (vitest.config
  already includes tests/backend/specs, tests/backend-new/specs, and
  tests/container/specs)
- test-utils: vitest run with --testTimeout 5000
- test-container: vitest run tests/container/specs/api
- test:vitest renamed to test:watch (watch mode for local dev)

.github/workflows/backend-tests.yml:
- removed the redundant 'Run the new vitest tests' step from all 4 jobs since
  pnpm test now runs vitest itself
2026-04-26 16:02:13 +02:00
SamTV12345
42907a7cc4 chore: continued with backend migration 2026-04-26 15:55:11 +02:00
SamTV12345
a8d7a3c5ad fix(pad.ts): replace exports.baseURL references with baseURL variable
Convert remaining exports.baseURL references to use the baseURL const defined at module level.
This completes the conversion from CommonJS require/exports to ESM import/export syntax.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:37:28 +02:00
SamTV12345
de2b51684a Convert remaining require() to import in static/js - batch 8
- pad_automatic_reconnect.ts: export const showCountDownTimerToReconnectOnModal
- pad_cookie.ts: convert to named export with const class instance
- pad_impexp.ts: export {padimpexp}
- pad_savedrevs.ts: export const saveNow, export const init
- skin_variants.ts: export multiple functions
- changesettracker.ts: export {makeChangesetTracker}
- broadcast_revisions.ts: export {loadBroadcastRevisionsJS}
- AttributeManager.ts: add .js extensions to imports, export default AttributeManager

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:34:55 +02:00
SamTV12345
50bf17c977 Convert require to import: timeslider (dynamic), vendors/jquery
- Convert dynamic requires to dynamic imports for circular dependency handling
- Make timeslider.init async to support dynamic imports
- Update exports references to use module scope or window
- Add ESM export default for jquery library
- Keep CommonJS compatibility for jquery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:32:18 +02:00
SamTV12345
f95e38eba6 Convert require to import: undomodule, pad_utils
- Convert const X = require('Y') to import X from 'Y.js'
- Add .js extensions to relative imports
- Convert exports.X = Y to export {X}
- Convert dynamic requires to dynamic imports for circular dependency handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:31:00 +02:00
SamTV12345
e7bc5cdef9 Convert require to import: rjquery, timeslider, underscore
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon', 'underscore')
- Convert exports.X = Y to export {X}
- Convert module.exports to export default

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:30:14 +02:00
SamTV12345
fea9e5128f Convert require to import: pad_editor, pad_modals, pad_userlist
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
- Refactor forward references to allow function reordering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:29:40 +02:00
SamTV12345
99774ee460 Convert require to import: pad, pad_connectionstatus, pad_editbar
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}
- Update self-references to avoid circular dependency issues

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:28:33 +02:00
SamTV12345
c2a0ff74a8 Convert require to import: domline, linestylefilter, broadcast
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:27:33 +02:00
SamTV12345
ab99abd35d Convert require to import: broadcast_slider, chat, collab_client
- Convert const X = require('Y') to import X from 'Y.js'
- Convert const {A, B} = require('Y') to import {A, B} from 'Y.js'
- Add .js extensions to relative imports
- Keep external packages without .js (e.g., 'tinycon/tinycon')
- Convert exports.X = Y to export {X}

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:26:23 +02:00
SamTV12345
503e3e209f fix: correct import statements for modules with named exports
Replace default imports with namespace imports for modules that only export
named exports:
- PadManager.ts: export const getPad, listAllPads, etc.
- AuthorManager.ts: export const getAuthor, etc.
- ImportHtml.ts: export const setPadHTML
- ExportHtml.ts: export const getPadHTMLDocument

Changed 'import X from Y' to 'import * as X from Y' in:
- Test files (export_list, chat, messages, etc.)
- Utility files (ExportHtml, ExportTxt, ExportEtherpad, ImportEtherpad, Cleanup)
- API test files (pad, restoreRevision)

This fixes ESM module resolution errors when these modules are imported as
default exports despite only providing named exports.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:19:13 +02:00
SamTV12345
8561acf247 Fix import statement for common module in test files
Change 'import common from' to 'import * as common from' in 20 test files
to use named imports instead of default import.

Co-authored-by: GitHub Copilot <copilot@github.com>
2026-04-26 14:08:49 +02:00
SamTV12345
e6ee5ee72d Convert test files to ESM (batch 5 - final): i18n, settings, hooks, webaccess, undo_clear_authorship, specialpages, socketio
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:07:24 +02:00
SamTV12345
37f082e413 Convert test files to ESM (batch 4): pads-with-spaces, largePaste, regression-db, lowerCasePadIds
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:06:38 +02:00
SamTV12345
53cde306d6 Convert test files to ESM (batch 3): export, clientvar_rev_consistency, sanitizePluginsForWire, messages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:06:12 +02:00
SamTV12345
fcfd7c42a0 Convert test files to ESM (batch 2): pad, favicon, chat, contentcollector, export_list
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:05:45 +02:00
SamTV12345
59ea987671 Convert test files to ESM (batch 1): apicalls, sessionsAndGroups, restoreRevision, instance, health, fuzzImportTest
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:05:13 +02:00
SamTV12345
22ad5364c7 refactor(pluginfw): finish installer.ts ESM conversion (3 stray requires) 2026-04-26 13:49:20 +02:00
SamTV12345
897ac4581a refactor(node): convert root files (server, metrics, prometheus, stats, padaccess) to ESM 2026-04-26 13:48:17 +02:00
SamTV12345
848ccdef69 refactor(pluginfw): convert hooks/plugins/plugin_defs/client_plugins/tsort to ESM, add createRequire bridge in shared.ts 2026-04-26 13:48:07 +02:00
SamTV12345
8644b7b5c9 refactor(node/hooks/express): convert remaining 7 files to ESM 2026-04-26 13:45:42 +02:00
SamTV12345
7cfc93ed9e refactor(node/types): ensure all 26 type files are ESM-clean 2026-04-26 13:43:26 +02:00
SamTV12345
63de3c6e30 refactor(node/hooks): partial CJS->ESM (express, i18n, 7 of 14 express/* files)
Done: express.ts (incl. https/http hoisted to top-level imports, exports.server
+ exports.sessionMiddleware -> export let), i18n.ts, admin.ts, webaccess.ts
(authnFailureDelayMs preserved as export let + setter), padurlsanitize.ts,
pwa.ts, errorhandling.ts (exports.app preserved as export let), tokenTransfer.ts,
adminplugins.ts.

Still CJS in hooks/express/: adminsettings, apicalls, importexport, openapi,
socketio, specialpages, static.
2026-04-26 13:41:33 +02:00
SamTV12345
18a290a83f refactor(node/handler): convert all 7 files to ESM
APIHandler / APIKeyHandler / SocketIORouter / ExportHandler / ImportHandler /
RestAPI: imports flipped to .js, exports.X -> export const X.
PadMessageHandler: full conversion incl. internal exports.X() callsites
(sendChatMessageToPadClients, updatePadClients, composePadChangesets) rewritten
to bare names. Default export object added so existing
`import padMessageHandler from '...'` callers keep working without changes.
Conditional require() of LibreOffice in ImportHandler/ExportHandler hoisted
to a top-level namespace import (`import * as converterModule`).
2026-04-26 13:38:29 +02:00
SamTV12345
79f54a4341 refactor(node/security): convert all 5 files to ESM
crypto.ts: util.promisify wrappers exported as named consts.
SecretRotator: import flips, all internal refs already named.
OIDCAdapter / OAuth2Provider / OAuth2User: already ESM, just .js suffixes.
2026-04-26 13:35:25 +02:00
SamTV12345
0e80e5f785 refactor(node/db): convert all 10 files to ESM
DB.ts: wrapped mutable exports as default-exported `dbModule` so init() can
still re-bind get/set/findKeys at runtime.
AuthorManager / GroupManager / PadManager / SessionManager: exports.X -> export
const X. Internal self-references (exports.X(...)) replaced with bare X(...).
Aliases (doesAuthorExists, doesPadExists, getAuthor4Token-deprecated)
preserved.
SecurityManager / ReadOnlyManager: imports flipped to .js.
SessionStore: module.exports -> export default.
Pad: `exports.cleanText` and `exports.Pad = Pad` now `export const cleanText`
and `export { Pad }`. Internal exports.cleanText() calls -> cleanText().
API: ~40 `exports.X = ...` rewritten to `export const X = ...`.
2026-04-26 13:34:40 +02:00
SamTV12345
fb7f2e22a3 refactor(node/eejs): convert to ESM (1 file)
Self-referential exports.X pattern replaced by a module-private `eejs` object
that's also the default export. __dirname shimmed via import.meta.url.
`require` (used by templates as args.require) preserved via createRequire.
2026-04-26 13:06:45 +02:00
SamTV12345
4e6d073213 refactor(node/utils): finish CJS->ESM (Settings, Minify, ExportEtherpad, ImportEtherpad, ExportHtml, toolbar)
All 26 files in node/utils/ now ESM. Settings.ts CJS shim removed (dead code
under "type": module); plugins doing require() get .default-wrapped namespace
via Node interop, the createRequire bridge in pluginfw will preserve sync
loading once that lands. toolbar.ts's module.exports.availableButtons
self-reference replaced by a module-private toolbar const.

ts-check: 526 -> 539 errors. The +13 is from default-imports landing on
modules that still live in db/ as CJS; resolves once db/ is flipped.
2026-04-26 13:06:08 +02:00
SamTV12345
5c3739f4df refactor(node/utils): partial CJS->ESM conversion (~14/26 files)
Converted: customError, Stream, NodeVersion, checkValidRev, AbsolutePaths
(+__dirname shim), run_cmd, Cleanup, ExportHelper, padDiff, ExportTxt,
LibreOffice, UpdateCheck, ImportHtml.

Still CJS in utils/: Settings, Minify, toolbar, ExportEtherpad,
ImportEtherpad, ExportHtml. Their consumers will surface errors until
they're flipped too. ts-check: 530 -> 526 errors.
2026-04-26 12:49:22 +02:00
SamTV12345
a26d7d3b63 build(src): switch package + tsconfig to ESM (type: module, NodeNext) 2026-04-26 12:17:55 +02:00
SamTV12345
ca6cf43315 chore: open branch for backend ESM + vitest migration 2026-04-26 12:15:00 +02:00
Etherpad Release Bot
547af5c2f0 Merge branch 'master' into develop 2026-04-26 09:35:14 +00:00
Etherpad Release Bot
dad6cc8eef bump version 2026-04-26 09:35:14 +00:00
Etherpad Release Bot
f1000e20fc Merge branch 'develop' 2026-04-26 09:35:14 +00:00
SamTV12345
a05bb7d7b3
chore: added release notes for 2.7.1 (#7604)
* chore: added release notes for 2.7.1

* chore: don't cache node_modules due to cas
2026-04-26 11:30:43 +02:00
John McLear
cd793294c4
fix(chat): icon click, disabled toggles, username layout (#7590, #7592, #7593) (#7597)
* fix(userlist): stop username input from overlapping the Log out button

Fixes #7593. In the pad's Users popup, #myusernameform had no width
set and the <input id="myusernameedit"> inside it took its natural
content width, pushing past the Log out button and making the button
overflow the popup at common widths.

Constrain #myusernameform to 75px and make the input fill its
container with box-sizing: border-box so the text field stays inside
the form and the Log out button sits visibly next to it rather than
getting covered or clipped off-screen.

Low-risk, CSS-only change. No test plan beyond visual verification
because the affected control is in the users popup UI.

* fix(chat): bottom-align titlebar controls; restore chat icon click (#7590)

Two regressions from the #7584 a11y refactor of the chat widget,
both pure-CSS fixes scoped to the chat panel.

1. Title bar — `<a>` → `<button>` for #titlecross/#titlesticky kept the
   `float: right` layout, but a `<button>`'s box is only as tall as its
   glyph, so the small `−` and `█` controls floated at the *top* of the
   44px title bar instead of sitting on the title's baseline as the
   anchors did. Switch #titlebar to a flex row with `align-items:
   flex-end`, give #titlelabel `flex: 1` to push the controls to the
   right edge, and use `order: 1/2` to keep the historical visual order
   `[█] [−]` (which `float: right` previously produced from reverse
   source order).

2. Chat-icon corner widget — `<div>` → `<button id="chaticon">` exposes
   the inner `<span class="buttonicon">` to the global `.buttonicon`
   rule's `display: flex; position: relative; align-items/justify-content:
   center;`. The existing override only reset `display`, leaving the
   span as a positioned flex item that, in some layouts, sat over the
   button's hit surface and swallowed clicks. Reset the remaining flex
   properties and add `pointer-events: none` so clicks always reach the
   `<button>`'s own click handler — preferred over weakening the global
   .buttonicon rule, which the toolbar relies on for icon centring.

Visual-only / behaviour-fix, no markup or JS changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): grey disabled chat option labels (#7592)

When "Disable chat" is ticked in the Settings dialog, refreshMyViewControls()
already sets `disabled` on `#options-stickychat` and `#options-chatandusers`,
but the browser only greys the checkbox itself — the adjacent `<label>`
keeps its normal colour, so the row still looks interactive even though
clicks are no-ops.

Add a popup-scoped rule that follows the existing convention used for
disabled `.nice-select` controls (`color: #999; cursor: not-allowed`) so
any disabled checkbox or radio in a settings popup matches its label to
the disabled state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(userlist): drop username input width cap (#7593 review)

The width:75px on #myusernameform and width:100%/box-sizing on
#myusernameedit from a55436ca0 were guarding against an overlap with
a "Log out" button — but no Log out button exists in vanilla
etherpad-lite (the original report came from a setup with a plugin
that adds one). Without that button visible, the cap just makes the
default username field unnecessarily narrow.

Restore #myusernameform to just `margin-left: 10px` and drop the
forced width on the input. If the overlap reappears in a real plugin
setup it should be re-fixed there (or with a more targeted rule that
only kicks in when a logout button is actually present).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): keep titlesticky at top of title bar (#7590 review)

The previous pass bottom-aligned both corner controls via
align-items: flex-end on #titlebar. That correctly placed the close
button (#titlecross) on the title's baseline, but it also dragged the
much smaller "stick to screen" button (#titlesticky) down to the same
baseline — visibly far below where it sat in the original layout.

Switch to per-control align-self so each lands where it should:
  - #titlesticky → align-self: flex-start  (top, where it always was)
  - #titlecross  → align-self: flex-end    (bottom, on the title's baseline)
  - #titlelabel  → align-self: center      (don't stretch the heading)

Drop align-items from #titlebar so the defaults don't override these.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(chat): restore original #titlebar layout (#7590 review)

Both attempted CSS layouts for the title bar (full flex with
align-items: flex-end, then per-control align-self) ended up looking
worse than the original in review. Drop all the #titlebar / #titlelabel
/ #titlecross / #titlesticky changes from 905294d5b and f37da9a62 and
restore the pre-existing float-based layout. The chat panel ships with
its original visuals; we'll revisit #7590 separately if needed.

Keeps the chat-icon click fix from 905294d5b (#chaticon .buttonicon
flex/pointer-events reset) and the focus-visible additions for the
title-bar buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): clear inline display:none in chat.show()

When the user disables chat in settings, applyShowChat(false) calls
\`$('#chatbox').hide()\` which sets the chatbox's inline display to
\`none\`. Re-enabling chat doesn't undo that — it only re-shows the
icon. Then clicking the icon runs chat.show(), which adds the
\`.visible\` class but only flips visibility, not display, so the
chatbox stays hidden by the lingering inline style and the chat
appears not to open.

Clear the inline display in chat.show() before adding the .visible
class so the box becomes visible regardless of how it got hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(colibris): align username gap; grey unchecked-disabled toggles

users.css: change #myusernameform margin-left from 35px to 10px to
match the base popup_users.css. The 35px value was chosen for the
sticky chatAndUsers layout, but for the standalone Users popup it
opens an unnecessarily wide gap between the colour swatch and the
username field. (#7593 review)

form.css: drop the \`:checked\` qualifier from the disabled toggle
visual rule so unchecked-but-disabled toggles also dim. Without this,
"Chat always on screen" / "Show Chat and Users" stayed fully bright
when "Disable chat" was ticked even though the underlying inputs were
disabled. Fixes #7592 in the colibris skin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): simple flex titlebar — CHAT      _  []

Single flex row, vertically centred via align-items: center. Title
takes the remaining width with flex: 1; the two corner controls fall
in at the right edge in source order (titlecross then titlesticky),
giving the intended visual: minus on the left, sticky on the right.

Drops `float: right` from the controls, `display: inline` from the
heading, and the prior `padding-top: 2px` hack on titlesticky (flex
alignment handles the vertical position now).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): titlebar uses underscore for minimize; symmetric padding

- Replace \`&minus;\` with \`_\` in #titlecross. The minus glyph sits at
  the centre of its em-box and read as a hyphen mid-row when the row
  was vertically centred; \`_\` sits at the bottom of its em-box and
  reads as a proper minimize indicator.
- Even out #titlebar horizontal padding to 9px and drop the asymmetric
  \`margin-left: 4px\` on #titlelabel so CHAT on the left and the
  sticky button on the right are the same distance from the bar's
  edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): lift #titlecross underscore 5px

The \`_\` glyph renders at the bottom of its em-box, so even with the
title bar's flex \`align-items: center\` it sits noticeably below the
CHAT baseline. Lift it with \`transform: translateY(-5px)\` (doesn't
affect flex layout calculations) so the underscore reads at roughly
the same vertical line as the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(chat): cover #7590 / #7592 / #7593 fixes

Adds Playwright frontend specs for the changes in this PR:

chat.spec.ts
  - chat icon click reveals chatbox after disable→enable cycle
    (regression: chat.show() must clear inline display:none)
  - title bar lays out as a centred flex row with underscore minimize
    (covers display, align-items, label flex:1, no float, translateY
    lift, and visual padding symmetry via rendered geometry)
  - chat icon click reliably opens the chat box (#chaticon .buttonicon
    pointer/flex reset)

pad_settings.spec.ts
  - disabling chat disables and visually greys the dependent chat
    toggles (#7592 — checks input :disabled state and label opacity)

change_user_name.spec.ts
  - #myusernameform has 10px left margin and is not width-capped
    (#7593 review — colibris margin alignment, no input width cap)

Padding symmetry asserted via rendered rect deltas rather than the
CSS literal, since colibris ships its own #titlebar padding override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:56:38 +01:00
John McLear
83a42afbae
fix(export): /export/etherpad honors the :rev URL segment (#7566)
Fixes #5071. `/p/:pad/:rev/export/etherpad` has always ignored the rev
parameter and returned the full pad history, unlike the txt/html
export endpoints which use the same route but do respect rev. Users
wanting to back up or inspect a snapshot of a pad at a specific rev
got every later revision in the payload instead — both wasteful and
a surprise when the downloaded .etherpad blob contained content that
had supposedly been reverted.

Change:
  - `exportEtherpad.getPadRaw(padId, readOnlyId, revNum?)` now takes an
    optional revNum. When supplied, it clamps to `min(revNum, pad.head)`,
    iterates only revs 0..effectiveHead, and ships a shallow-cloned pad
    object whose `head` and `atext` reflect the requested snapshot. The
    original live Pad is still passed to the `exportEtherpad` hook so
    plugin callbacks see the real document.
  - `ExportHandler` passes `req.params.rev` through on the `etherpad`
    type, matching the existing behavior of `txt` and `html`.
  - Chat history is intentionally left full (it is not rev-anchored).

Adds three backend regression tests under `ExportEtherpad.ts`:
  - default (no revNum) still exports the full history
  - explicit revNum limits exported revs and rewrites the serialized
    head so re-import reconstructs the pad at that rev
  - revNum above head is treated as full history, preventing accidental
    truncation of short pads

Out of scope: `getHTML(padID, rev)` on the API side is already honoring
rev in current code (exportHtml.getPadHTML threads the parameter
through), so the earlier report on that API call appears to be
resolved. This PR does not touch it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:29:02 +02:00
John McLear
51356b9a13
fix(editor): undo/redo scrolls the viewport to follow the caret (#7562)
* fix(editor): undo/redo scrolls the viewport to follow the caret

Before: on a large pad, pressing Ctrl+Z (or Ctrl+Y, or the toolbar undo
button) updated the caret in the rep model and the DOM, but the viewport
did not follow when the caret landed below the visible area. The user
was left looking at the same scroll position while their change had
been undone somewhere they couldn't see.

Root cause: scroll.ts's `caretIsBelowOfViewport` branch ran
`outer.scrollTo(0, outer[0].innerHeight)` — a fixed offset equal to the
inner iframe's height, NOT the caret position. That was a special-case
added in PR #4639 to keep the caret visible when the user pressed Enter
at the very end of the pad. It worked for that one scenario because the
newly-appended `<div>` happened to be at the bottom of the pad too; for
any other way of putting the caret below the viewport (undo, redo,
programmatic selection change, deletion that collapsed a long block) it
scrolled to an arbitrary spot.

Fix: mirror the `caretIsAboveOfViewport` branch. After the deferred
render settles, recompute the caret's position relative to the viewport
and scroll by exactly the delta needed to bring the caret back in — plus
the configured margin. The Enter-at-last-line case still works because
the caret genuinely is near the bottom of the pad and the delta resolves
to "scroll down by a screen".

Closes #7007

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(7007): use real typing so undo has changesets to replay

The first iteration of the Playwright spec built the pad by writing
directly to #innerdocbody.innerHTML. That bypasses Etherpad's text
layer, so the undo module had no changeset to revert — Ctrl+Z became a
no-op and the scroll assertion saw no movement (CI failure output:
`Expected: < 2302, Received: 2302`).

Replace with real keyboard typing of 45 lines via the existing
writeToPad-style pattern, then make the edit + scroll + Ctrl+Z under
that real content. Slower (~5s per test) but faithful to how undo
interacts with the pad.

Also drop the `test.beforeEach(clearCookies)` scaffolding — it wasn't
doing anything useful here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(7007): scroll caret into view directly in doUndoRedo

Revert the scroll.ts rewrite from the previous commits and move the
fix to the right abstraction layer: the undo/redo entry point itself.

`scrollNodeVerticallyIntoView`'s caret-below-viewport branch has a
well-documented special case (PR #4639) that scrolls to the inner
iframe's innerHeight so Enter-on-last-line stays smooth. Changing
that function for the undo case risked regressing the Enter case or
racing with the existing scrollY bookkeeping. The CI run showed the
rewrite wasn't actually producing viewport movement.

Do the simpler thing instead: in `doUndoRedo`, after the selection is
updated, call `Element.scrollIntoView({block: "center"})` on the
caret's line node. That's browser-native, works inside the
ace_inner / ace_outer iframe chain, doesn't need setTimeout, and matches
what gedit/libreoffice do.

Closes #7007

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:26:54 +02:00
John McLear
37aaeaf197
fix: page down/up scrolls by viewport height, not line count (#7479)
* fix: page down/up now scrolls by viewport height, not line count

The previous implementation counted logical lines in the viewport,
which failed when long wrapped lines consumed the entire viewport.
Now scrolls by actual pixel height for correct behavior.

Fixes #4562

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use outerDoc instead of outerWin.document for viewport height in PageDown/Up

outerWin is an HTMLIFrameElement (returned by getElementsByName), not a
Window object, so it has no .document property. The existing getInnerHeight()
helper already uses outerDoc.documentElement.clientHeight correctly; align
the PageDown/PageUp handler with that pattern.

Adds a Playwright regression test that verifies PageDown scrolls the
viewport when the pad contains long wrapping lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rewrite page down/up to use pixel-based line counting

The previous approach tried to scroll the outerWin iframe element
directly which didn't work. Reverted to the original cursor-movement
approach but calculates lines-to-skip using viewport pixel height
divided by actual rendered line heights. This correctly handles long
wrapped lines that consume multiple visual rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore getInnerHeight + inclusive range fixes lost in rebase

Recover the PageDown/Up fixes that got dropped when this branch was
rebased onto develop:

- Use getInnerHeight() instead of outerDoc.documentElement.clientHeight
  so hidden-iframe and Opera edge cases are handled the same as the rest
  of the editor.
- scroll.getVisibleLineRange() returns an inclusive end index, so count
  (end - start + 1) logical lines to match the pixel-sum loop bounds.
- Replace the flaky 'PageDown scrolls viewport' test with the robust
  #4562 regression that builds long wrapped lines via direct DOM and
  asserts the caret advances on successive PageDown presses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 10:19:23 +02:00
John McLear
f219809a3d
fix(editbar): restore caret to pad after toolbar-select change (#7589) (#7598)
After picking a value from a toolbar <select> (ep_headings style
picker is the canonical case), keyboard focus was left on the
nice-select wrapper rather than returned to the pad editor. Users
had to click back into the pad before typing resumed.

The ToolbarItem.bind() class already calls padeditor.ace.focus() at
the end of triggerCommand, but that only runs for selects wired via
data-key on the wrapping <li>. Plugin-provided selects (e.g.
ep_headings2's #heading-selection inside <li id="headings">, no
data-key) don't go through that path — they bind their own change
handler and never return focus.

Fix: add a delegated change handler on `#editbar select` that calls
padeditor.ace.focus() after any toolbar select change. Deferred via
setTimeout(0) so plugin change handlers (bound on the same event)
complete their ace.callWithAce work before focus moves. Redundant but
harmless for data-key-wired selects that are already refocused by
triggerCommand.

Added a Playwright regression test that simulates the nice-select
option-click (val + change, which is what the wrapper dispatches
internally) and verifies typing after the change lands in the pad.
Skips when ep_headings2 isn't installed.

Closes #7589.
2026-04-26 08:20:40 +01:00
John McLear
5fd600d608
fix(admin): restore i18n on /admin (issue #7586) (#7602)
* fix(admin): restore i18n on /admin by copying locales to the right path

The admin SPA fetches `/admin/locales/<lang>.json`. Building with
vite-plugin-static-copy and `src: '../src/locales'` was placing the
115 core locale files at `src/templates/admin/src/locales/` (the
plugin's `dirClean` strips a leading `../` but keeps the remaining
parent path). The express admin handler 404'd those fetches, fell
back to serving `index.html`, JSON.parse silently failed, and every
`<Trans>` rendered its raw key — see #7586.

Replace the plugin with a small inline build/dev plugin: at build
time copy `src/locales/*.json` to `<outDir>/locales/`; in dev serve
the same files via middleware so `vite dev` also works. Drop the
now-unused `vite-plugin-static-copy` dependency.

Add regression coverage that none of the existing admin specs had:
- backend HTTP test for GET /admin/locales/{en,de}.json
- Playwright admin i18n spec asserting translated <h1> renders for
  the default locale and for ?lng=de, plus a request-level check
  that the response is JSON, not the SPA fallback.

Closes #7586

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(admin): bundle locales via import.meta.glob, drop copy plugin

The first pass at #7586 replaced vite-plugin-static-copy with a
custom build/dev plugin that copied src/locales/*.json into the
admin output and served them in dev. That works, but the
vite-plugin-static-copy README explicitly recommends the public
directory or a JS import for this case, and the import path is
strictly cleaner: no copy step, no /admin/locales/* express route,
no SPA-fallback-shaped failure mode.

Use import.meta.glob in admin/src/localization/i18n.ts so each
language ships as its own hashed JSON chunk and is lazy-loaded on
demand. The vite config goes back to just react + base + outDir.
The plugin namespaces (e.g. ep_admin_pads) keep their existing
admin/public/<ns>/<lang>.json layout.

Tests:
- Drop tests/backend/specs/adminLocales.ts — it asserted on a
  /admin/locales/<lang>.json route that this approach no longer
  uses; the regression mechanism it pinned doesn't exist anymore
  and the test required the admin frontend to be built before the
  backend test runs (which CI doesn't do).
- Keep tests/frontend-new/admin-spec/admini18n.spec.ts (rendered
  <h1> in default and ?lng=de). Verified red→green: reverting just
  the loader to the pre-fix /admin/locales fetch makes both specs
  fail; restoring makes them pass.

Also update pnpm-lock.yaml to drop the now-unused
vite-plugin-static-copy entries — fixes ERR_PNPM_OUTDATED_LOCKFILE
that was failing every CI install upfront.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 03:07:45 +01:00
dependabot[bot]
ea57f764ad
build(deps-dev): bump lucide-react in the dev-dependencies group (#7600)
Bumps the dev-dependencies group with 1 update: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


Updates `lucide-react` from 1.8.0 to 1.11.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.11.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-25 08:07:35 +01:00
dependabot[bot]
145e44a501
build(deps): bump mssql from 12.3.1 to 12.5.0 (#7595)
Bumps [mssql](https://github.com/tediousjs/node-mssql) from 12.3.1 to 12.5.0.
- [Release notes](https://github.com/tediousjs/node-mssql/releases)
- [Changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt)
- [Commits](https://github.com/tediousjs/node-mssql/compare/v12.3.1...v12.5.0)

---
updated-dependencies:
- dependency-name: mssql
  dependency-version: 12.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 03:06:06 +01:00
dependabot[bot]
b220f685c6
build(deps-dev): bump i18next in the dev-dependencies group (#7594)
Bumps the dev-dependencies group with 1 update: [i18next](https://github.com/i18next/i18next).


Updates `i18next` from 26.0.6 to 26.0.7
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.6...v26.0.7)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 03:05:29 +01:00
dependabot[bot]
d189022f3b
build(deps): bump express-rate-limit from 8.3.2 to 8.4.0 (#7596)
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.3.2 to 8.4.0.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.3.2...v8.4.0)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 03:05:16 +01:00
John McLear
7b9a5eb01a
fix(a11y): dialog semantics, focus management, icon labels, html lang (#7584)
* fix(a11y): negotiate lang/dir per request and set on <html>

Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.

This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist

Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:

  - #settings, #import_export, #embed, #skin-variants (labelledby)
  - #connectivity, #users, #mycolorpicker (aria-label)

Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.

Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): focus management and Escape-to-close for popups

Three additions to toggleDropDown / _bodyKeyEvent:

  - Remember the trigger element (document.activeElement) when opening
    a popup, so we can restore focus when it closes.
  - On open, focus the first focusable element inside the popup so
    keyboard users land inside the dialog instead of staying on the
    trigger button.
  - Escape pressed while focus is inside a popup closes it, then the
    restore-focus path runs and the trigger button is refocused.

Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): make chaticon and chat header controls real buttons

- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
  with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
  read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
  font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users

Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): accessible names for icon-only toolbar/export controls

- Export links (#exportetherpada, #exporthtmla, #exportplaina,
  #exportworda, #exportpdfa, #exportopena): added aria-label so the
  link is announced as e.g. "Export as PDF". The inner icon span
  gets aria-hidden=true so screen readers don't read both the icon
  text and the link label.

- Show-more toolbar toggle (.show-more-icon-btn): converted from
  <span> to <button type=button> with aria-label and aria-expanded.
  The click handler now toggles aria-expanded alongside the
  full-icons class so assistive tech reflects the open/closed state.

- Theme switcher knob: aria-label changed from "theme-switcher-knob"
  (a class-style identifier, not human text) to "Toggle theme".

Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(a11y): cover dialog semantics, html lang, icon button labels

New Playwright spec verifies the a11y guarantees added by this branch:

  - <html> has a non-empty lang attribute
  - settings/import_export/embed/users popups expose role=dialog,
    aria-modal=true, and either aria-labelledby (when an h1 exists)
    or aria-label (when none does)
  - Escape from inside the settings popup closes it AND restores
    focus to the trigger button
  - Export links each carry a descriptive aria-label
  - #chaticon is a real <button> with aria-label
  - #titlecross / #titlesticky are real <button>s with aria-label
  - #otherusers uses role=region + aria-live=polite + aria-label
    (and the previous aria-role typo is gone)
  - .show-more-icon-btn is a <button> with aria-label and
    aria-expanded

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): address Qodo review feedback from PR #7584

1. Users Escape close broken - toggleDropDown('none') intentionally
   skips the users module so switching between other popups doesn't
   hide the user list. That meant Escape couldn't dismiss the Users
   popup either. The Escape branch now checks for #users as the
   focused popup and closes it explicitly (respecting stickyUsers)
   before falling through to the normal close-all path.

2. Embed focus overridden - the rAF auto-focus in toggleDropDown
   grabbed the first focusable descendant, which stole focus from
   command handlers that target a specific control (notably the Embed
   command's #linkinput). rAF now bails out if focus is already
   inside the newly-opened popup.

3. Button click blurs :focus before toggleDropDown captures trigger -
   discovered while investigating the Firefox Playwright failure for
   "settings popup Escape restores focus". Button.bind() calls
   $(':focus').trigger('blur') before invoking the callback, so by
   the time toggleDropDown() captured document.activeElement as the
   restore target it was already <body>. The click handler now
   stashes padeditbar._lastTrigger to the clicked <button> before
   blur runs; toggleDropDown only falls back to activeElement when
   the pre-stash didn't happen (keyboard shortcut path).

4. html10n overwrites aria-label - html10n unconditionally set
   aria-label to the translated string, clobbering explicit aria-label
   on elements that also carry data-l10n-id. setAttribute now only
   fires when the element has no aria-label; explicit author labels
   win, unlabelled translated elements still get a name.

5. Button visual reset - the show-more-icon-btn and #chaticon
   conversions inherited UA default button border/background/padding,
   shifting icon glyphs visibly off-centre. Added appearance /
   background / border / padding resets.

6. Export links test assumes soffice is installed - #exportworda,
   #exportpdfa, #exportopena are removed client-side by pad_impexp.ts
   when clientVars.exportAvailable === 'no'. The test now skips links
   absent at runtime.

Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.

* fix(a11y): close popups with no focusable content; unbreak chat-icon layout

Round 2 of #7584 review follow-ups.

1. Users popup Escape still didn't close the dialog (user-confirmed).
   Root cause: _bodyKeyEvent is bound to the OUTER document's body.
   When #users opens, the command handler tries to focus
   #myusernameedit but that input is `disabled`, so focus stays in the
   ace editor iframe. Keydown from inside the iframe does not bubble
   to the outer document, so Esc never reaches _bodyKeyEvent.
   Fix: in the open-popup rAF, if no command handler placed focus
   inside the dialog, focus the popup div itself (with tabindex=-1).
   That keeps subsequent keydown events on the outer document so
   Esc can dismiss the popup. Also broadened the Esc branch to fire
   whenever any popup is `.popup-show`, regardless of where :focus
   lives — some popups legitimately have no focusable content at
   open.
   Added a regression test that opens #users and asserts Esc closes
   it. Passes on both Chromium and Firefox.

2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
   - My previous `border: 0` reset was overriding the intended
     `border: 1px solid #ccc; border-bottom: none` from the earlier
     rule. Removed `border: 0`; the earlier explicit border suffices
     to suppress UA defaults.
   - The `<span class="buttonicon">` inside `#chaticon` was picking
     up the global `.buttonicon { display: flex; }` rule meant for
     toolbar button instances, which broke the inline layout of the
     label + glyph + counter row. Added a scoped
     `#chaticon .buttonicon { display: inline; }` override.

All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.

* fix(a11y): only stash _lastTrigger for dropdown-opening buttons

Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.

Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.

Verified locally on Chromium:
  - ordered_list.spec.ts: 6/6 pass (was 4/6)
  - unordered_list.spec.ts: 6/6 pass (was 4/6)
  - undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
  - a11y_dialogs.spec.ts: 11/11 pass (unchanged)

* fix(a11y): address Qodo review round 4 for PR #7584

#1 Stale aria-label after relocalize
  html10n.translateNode() refused to overwrite any existing aria-label,
  which also skipped updates on language change (pad.applyLanguage()
  re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
  aria-label + marker when html10n populates it, overwrite only if the
  marker is present. Explicit template-supplied aria-labels stay as-is;
  html10n-generated ones refresh on relocalize.

#2 Escape won't close colorpicker
  _bodyKeyEvent caught Escape on any `.popup.popup-show` but only
  closed dropdown popups via toggleDropDown('none'). Popups opened
  outside the editbar framework (#mycolorpicker, toggled directly by
  pad_userlist.ts) stayed open while preventDefault() swallowed the
  key. Now the Escape branch manually closes any popup that
  toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
  unless pinned) and leaves registered dropdowns for toggleDropDown to
  close so its focus-restore sees the transition.

#3 Stale focus restoration
  toggleDropDown('none') restored focus to _lastTrigger even when no
  popup was open on entry, which meant background callers
  (connectivity setup, periodic state handling) could yank focus out
  of the editor to a stale toolbar button. Gated the restore on
  `wasAnyOpen === true` so it only fires when there was a popup to
  close.

#11 English aria-label overrides i18n (export links, chat icon)
  Removed the hard-coded English aria-label from export anchors and
  removed aria-hidden from their inner localized spans. Screen readers
  now get the localized child text as the accessible name (Etherpad,
  HTML, PDF, etc.), matching the visible UI language.
  Removed the English aria-label from #chaticon and #titlesticky as
  well — both have data-l10n-id, so html10n populates a localized
  aria-label via the marker mechanism in #1. #titlecross keeps its
  static aria-label because it has no data-l10n-id yet.

#4 4-space indent in a11y spec
  Two tests had continuation lines at 4-space indent violating the
  repo's 2-space rule. Folded the signatures onto one line.

Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).

Verified locally (dev server restarted for each round):
  - a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
  - ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
  - Full backend suite: 799 passing, 0 failing
  - tsc --noEmit clean in our code

#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:04:18 +01:00
Etherpad Release Bot
6acca4d57e Merge branch 'master' into develop 2026-04-23 21:08:26 +00:00
Etherpad Release Bot
648f6222ef Merge branch 'develop' 2026-04-23 21:08:25 +00:00
Etherpad Release Bot
249241d9a4 bump version 2026-04-23 21:08:25 +00:00