Merge branch 'develop'

This commit is contained in:
SamTV12345 2024-09-05 18:55:46 +02:00
commit dd83164b22
159 changed files with 4971 additions and 24500 deletions

View file

@ -69,6 +69,9 @@ jobs:
- -
name: Run the backend tests name: Run the backend tests
run: pnpm test run: pnpm test
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
withpluginsLinux: withpluginsLinux:
# run on pushes to any branch # run on pushes to any branch
@ -142,6 +145,9 @@ jobs:
- -
name: Run the backend tests name: Run the backend tests
run: pnpm test run: pnpm test
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
withoutpluginsWindows: withoutpluginsWindows:
# run on pushes to any branch # run on pushes to any branch
@ -193,7 +199,11 @@ jobs:
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
- -
name: Run the backend tests name: Run the backend tests
run: cd src && pnpm test working-directory: src
run: pnpm test
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
withpluginsWindows: withpluginsWindows:
# run on pushes to any branch # run on pushes to any branch
@ -273,4 +283,8 @@ jobs:
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json" powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
- -
name: Run the backend tests name: Run the backend tests
run: cd src && pnpm test working-directory: src
run: pnpm test
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest

View file

@ -50,7 +50,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v3 uses: actions/cache@v4
id: playwright-cache id: playwright-cache
with: with:
path: | path: |

View file

@ -57,7 +57,7 @@ jobs:
name: Create settings.json name: Create settings.json
run: cp ./src/tests/settings.json settings.json run: cp ./src/tests/settings.json settings.json
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v3 uses: actions/cache@v4
id: playwright-cache id: playwright-cache
with: with:
path: | path: |
@ -127,7 +127,7 @@ jobs:
- name: Create settings.json - name: Create settings.json
run: cp ./src/tests/settings.json settings.json run: cp ./src/tests/settings.json settings.json
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v3 uses: actions/cache@v4
id: playwright-cache id: playwright-cache
with: with:
path: | path: |
@ -175,7 +175,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- name: Cache playwright binaries - name: Cache playwright binaries
uses: actions/cache@v3 uses: actions/cache@v4
id: playwright-cache id: playwright-cache
with: with:
path: | path: |

View file

@ -1,3 +1,14 @@
# 2.2.3
### Notable enhancements and fixes
- Introduced a new in process database `rustydb` that represents a fast key value store written in Rust.
- Readded window._ as a shortcut for getting text
- Added support for migrating any ueberdb database to another. You can now switch as you please. See here: https://docs.etherpad.org/cli.html
- Further Typescript movements
- A lot of security issues fixed and reviewed in this release. Please update.
# 2.2.2 # 2.2.2
### Notable enhancements and fixes ### Notable enhancements and fixes

View file

@ -49,6 +49,14 @@ ARG ETHERPAD_PLUGINS=
# ETHERPAD_LOCAL_PLUGINS="../ep_my_plugin ../ep_another_plugin" # ETHERPAD_LOCAL_PLUGINS="../ep_my_plugin ../ep_another_plugin"
ARG ETHERPAD_LOCAL_PLUGINS= ARG ETHERPAD_LOCAL_PLUGINS=
# github plugins to install while building the container. By default no plugins are
# installed.
# If given a value, it has to be a space-separated, quoted list of plugin names.
#
# EXAMPLE:
# ETHERPAD_GITHUB_PLUGINS="ether/ep_plugin"
ARG ETHERPAD_GITHUB_PLUGINS=
# Control whether abiword will be installed, enabling exports to DOC/PDF/ODT formats. # Control whether abiword will be installed, enabling exports to DOC/PDF/ODT formats.
# By default, it is not installed. # By default, it is not installed.
# If given any value, abiword will be installed. # If given any value, abiword will be installed.
@ -114,13 +122,13 @@ COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./
FROM build AS development FROM build AS development
COPY --chown=etherpad:etherpad ./src/package.json .npmrc ./src/ COPY --chown=etherpad:etherpad ./src/ ./src/
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/ templates/admin./src/templates/admin COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/ templates/admin./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
RUN bin/installDeps.sh && \ RUN bin/installDeps.sh && \
if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ] || [ ! -z "${ETHERPAD_GITHUB_PLUGINS}" ]; then \
pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_LOCAL_PLUGINS:+--path ${ETHERPAD_LOCAL_PLUGINS}}; \ pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_LOCAL_PLUGINS:+--path ${ETHERPAD_LOCAL_PLUGINS}} ${ETHERPAD_GITHUB_PLUGINS:+--github ${ETHERPAD_GITHUB_PLUGINS}}; \
fi fi
@ -134,11 +142,10 @@ COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/template
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
RUN bin/installDeps.sh && rm -rf ~/.npm && rm -rf ~/.local && rm -rf ~/.cache && \ RUN bin/installDeps.sh && rm -rf ~/.npm && rm -rf ~/.local && rm -rf ~/.cache && \
if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ]; then \ if [ ! -z "${ETHERPAD_PLUGINS}" ] || [ ! -z "${ETHERPAD_LOCAL_PLUGINS}" ] || [ ! -z "${ETHERPAD_GITHUB_PLUGINS}" ]; then \
pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_LOCAL_PLUGINS:+--path ${ETHERPAD_LOCAL_PLUGINS}}; \ pnpm run plugins i ${ETHERPAD_PLUGINS} ${ETHERPAD_LOCAL_PLUGINS:+--path ${ETHERPAD_LOCAL_PLUGINS}} ${ETHERPAD_GITHUB_PLUGINS:+--github ${ETHERPAD_GITHUB_PLUGINS}}; \
fi fi
# Copy the configuration file. # Copy the configuration file.
COPY --chown=etherpad:etherpad ${SETTINGS} "${EP_DIR}"/settings.json COPY --chown=etherpad:etherpad ${SETTINGS} "${EP_DIR}"/settings.json

View file

@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"private": true, "private": true,
"version": "2.2.2", "version": "2.2.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -16,27 +16,27 @@
"devDependencies": { "devDependencies": {
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@types/react": "^18.3.2", "@types/react": "^18.3.5",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.2.25",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.4.0",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.4.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.8.0", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.9", "eslint-plugin-react-refresh": "^0.4.11",
"i18next": "^23.12.2", "i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.426.0", "lucide-react": "^0.438.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.52.2", "react-hook-form": "^7.53.0",
"react-i18next": "^15.0.1", "react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.0", "vite": "^5.4.3",
"vite-plugin-static-copy": "^1.0.6", "vite-plugin-static-copy": "^1.0.6",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"zustand": "^4.5.4" "zustand": "^4.5.5"
} }
} }

View file

@ -802,3 +802,12 @@ input, button, select, optgroup, textarea {
background-color: var(--etherpad-color); background-color: var(--etherpad-color);
color: white color: white
} }
.search-pads{
text-align: center;
}
.search-pads-body tr td:last-child {
display: flex;
justify-content: center;
}

View file

@ -6,14 +6,51 @@ import {Trans, useTranslation} from "react-i18next";
import {SearchField} from "../components/SearchField.tsx"; import {SearchField} from "../components/SearchField.tsx";
import {Download, Trash} from "lucide-react"; import {Download, Trash} from "lucide-react";
import {IconButton} from "../components/IconButton.tsx"; import {IconButton} from "../components/IconButton.tsx";
import {determineSorting} from "../utils/sorting.ts";
export const HomePage = () => { export const HomePage = () => {
const pluginsSocket = useStore(state=>state.pluginsSocket) const pluginsSocket = useStore(state=>state.pluginsSocket)
const [plugins,setPlugins] = useState<PluginDef[]>([]) const [plugins,setPlugins] = useState<PluginDef[]>([])
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([]) const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([])
const [searchParams, setSearchParams] = useState<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'name',
sortDir: 'asc',
searchTerm: ''
})
const filteredInstallablePlugins = useMemo(()=>{
return plugins.sort((a, b)=>{
if(searchParams.sortBy === "version"){
if(searchParams.sortDir === "asc"){
return a.version.localeCompare(b.version)
}
return b.version.localeCompare(a.version)
}
if(searchParams.sortBy === "last-updated"){
if(searchParams.sortDir === "asc"){
return a.time.localeCompare(b.time)
}
return b.time.localeCompare(a.time)
}
if (searchParams.sortBy === "name") {
if(searchParams.sortDir === "asc"){
return a.name.localeCompare(b.name)
}
return b.name.localeCompare(a.name)
}
return 0
})
}, [plugins, searchParams])
const sortedInstalledPlugins = useMemo(()=>{ const sortedInstalledPlugins = useMemo(()=>{
return installedPlugins.sort((a, b)=>{ return installedPlugins.sort((a, b)=>{
if(a.name < b.name){ if(a.name < b.name){
return -1 return -1
} }
@ -23,14 +60,8 @@ export const HomePage = () => {
return 0 return 0
}) })
} ,[installedPlugins]) } ,[installedPlugins, searchParams])
const [searchParams, setSearchParams] = useState<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'name',
sortDir: 'asc',
searchTerm: ''
})
const [searchTerm, setSearchTerm] = useState<string>('') const [searchTerm, setSearchTerm] = useState<string>('')
const {t} = useTranslation() const {t} = useTranslation()
@ -165,16 +196,35 @@ export const HomePage = () => {
<table id="available-plugins"> <table id="available-plugins">
<thead> <thead>
<tr> <tr>
<th><Trans i18nKey="admin_plugins.name"/></th> <th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'name')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'name',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}>
<Trans i18nKey="admin_plugins.name" /></th>
<th style={{width: '30%'}}><Trans i18nKey="admin_plugins.description"/></th> <th style={{width: '30%'}}><Trans i18nKey="admin_plugins.description"/></th>
<th><Trans i18nKey="admin_plugins.version"/></th> <th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'version')} onClick={()=>{
<th><Trans i18nKey="admin_plugins.last-update"/></th> setSearchParams({
...searchParams,
sortBy: 'version',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}><Trans i18nKey="admin_plugins.version"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'last-updated')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'last-updated',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}><Trans i18nKey="admin_plugins.last-update"/></th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th> <th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr> </tr>
</thead> </thead>
<tbody style={{overflow: 'auto'}}> <tbody style={{overflow: 'auto'}}>
{(plugins.length > 0) ? {(filteredInstallablePlugins.length > 0) ?
plugins.map((plugin) => { filteredInstallablePlugins.map((plugin) => {
return <tr key={plugin.name}> return <tr key={plugin.name}>
<td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td> <td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
<td>{plugin.description}</td> <td>{plugin.description}</td>

View file

@ -104,7 +104,7 @@ export const PadPage = ()=>{
<SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/> <SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
<table> <table>
<thead> <thead>
<tr> <tr className="search-pads">
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{ <th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
setSearchParams({ setSearchParams({
...searchParams, ...searchParams,
@ -136,7 +136,7 @@ export const PadPage = ()=>{
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th> <th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="search-pads-body">
{ {
pads?.results?.map((pad)=>{ pads?.results?.map((pad)=>{
return <tr key={pad.padName}> return <tr key={pad.padName}>

View file

@ -20,7 +20,7 @@ export type SearchParams = {
searchTerm: string, searchTerm: string,
offset: number, offset: number,
limit: number, limit: number,
sortBy: 'name'|'version', sortBy: 'name'|'version'|'last-updated',
sortDir: 'asc'|'desc' sortDir: 'asc'|'desc'
} }

63
bin/make_docs.ts Normal file
View file

@ -0,0 +1,63 @@
import {exec} from 'child_process'
import fs from 'fs'
import path from 'path'
import pjson from '../src/package.json'
const VERSION=pjson.version
console.log(`Building docs for version ${VERSION}`)
const createDirIfNotExists = (dir: fs.PathLike) => {
if (!fs.existsSync(dir)){
fs.mkdirSync(dir)
}
}
function copyFolderSync(from: fs.PathLike, to: fs.PathLike) {
if(fs.existsSync(to)){
const stat = fs.lstatSync(to)
if (stat.isDirectory()){
fs.rmSync(to, { recursive: true })
}
else{
fs.rmSync(to)
}
}
fs.mkdirSync(to);
fs.readdirSync(from).forEach(element => {
if (fs.lstatSync(path.join(<string>from, element)).isFile()) {
if (typeof from === "string") {
if (typeof to === "string") {
fs.copyFileSync(path.join(from, element), path.join(to, element))
}
}
} else {
if (typeof from === "string") {
if (typeof to === "string") {
copyFolderSync(path.join(from, element), path.join(to, element))
}
}
}
});
}
exec('asciidoctor -v', (err,stdout)=>{
if (err){
console.log('Please install asciidoctor')
console.log('https://asciidoctor.org/docs/install-toolchain/')
process.exit(1)
}
});
createDirIfNotExists('../out')
createDirIfNotExists('../out/doc')
createDirIfNotExists('../out/doc/api')
exec(`asciidoctor -D ../out/doc ../doc/index.adoc */**.adoc -a VERSION=${VERSION}`)
exec(`asciidoctor -D ../out/doc/api ../doc/api/*.adoc -a VERSION=${VERSION}`)
copyFolderSync('../doc/public/', '../out/doc/')

83
bin/migrateDB.ts Normal file
View file

@ -0,0 +1,83 @@
// DB migration
import {readFileSync} from 'node:fs'
import {Database, DatabaseType} from "ueberdb2";
import path from "node:path";
const settings = require('ep_etherpad-lite/node/utils/Settings');
// file1 = source, file2 = target
// pnpm run migrateDB --file1 <db1.json> --file2 <db2.json>
const arg = process.argv.slice(2);
if (arg.length != 4) {
console.error('Wrong number of arguments!. Call with pnpm run migrateDB --file1 source.json target.json')
process.exit(1)
}
type SettingsConfig = {
dbType: string,
dbSettings: any
}
/*
{
"dbType": "<your-db-type>",
"dbSettings": {
<your-db-settings>
}
}
*/
let firstDBSettingsFile: string
let secondDBSettingsFile: string
if (arg[0] == "--file1") {
firstDBSettingsFile = arg[1]
} else if (arg[0] === "--file2") {
secondDBSettingsFile = arg[1]
}
if (arg[2] == "--file1") {
firstDBSettingsFile = arg[3]
} else if (arg[2] === "--file2") {
secondDBSettingsFile = arg[3]
}
const settingsfile = JSON.parse(readFileSync(path.join(settings.root,firstDBSettingsFile!)).toString()) as SettingsConfig
const settingsfile2 = JSON.parse(readFileSync(path.join(settings.root,secondDBSettingsFile!)).toString()) as SettingsConfig
console.log(settingsfile2)
if ("filename" in settingsfile.dbSettings) {
settingsfile.dbSettings.filename = path.join(settings.root, settingsfile.dbSettings.filename)
console.log(settingsfile.dbType + " location is "+ settingsfile.dbSettings.filename)
}
if ("filename" in settingsfile2.dbSettings) {
settingsfile2.dbSettings.filename = path.join(settings.root, settingsfile2.dbSettings.filename)
console.log(settingsfile2.dbType + " location is "+ settingsfile2.dbSettings.filename)
}
const ueberdb1 = new Database(settingsfile.dbType as DatabaseType, settingsfile.dbSettings)
const ueberdb2 = new Database(settingsfile2.dbType as DatabaseType, settingsfile2.dbSettings)
const handleSync = async ()=>{
await ueberdb1.init()
await ueberdb2.init()
const allKeys = await ueberdb1.findKeys('*','')
for (const key of allKeys) {
const foundVal = await ueberdb1.get(key)!
await ueberdb2.set(key, foundVal)
}
}
handleSync().then(()=>{
console.log("Done syncing dbs")
}).catch(e=>{
console.log(`Error syncing db ${e}`)
})

View file

@ -1,25 +1,26 @@
{ {
"name": "bin", "name": "bin",
"version": "2.2.2", "version": "2.2.3",
"description": "", "description": "",
"main": "checkAllPads.js", "main": "checkAllPads.js",
"directories": { "directories": {
"doc": "doc" "doc": "doc"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.3", "axios": "^1.7.7",
"ep_etherpad-lite": "workspace:../src", "ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"semver": "^7.6.3", "semver": "^7.6.3",
"tsx": "^4.17.0", "tsx": "^4.19.0",
"ueberdb2": "^4.2.92" "ueberdb2": "^4.2.100"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.1.0", "@types/node": "^22.5.4",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"scripts": { "scripts": {
"makeDocs": "node --import tsx make_docs.ts",
"checkPad": "node --import tsx checkPad.ts", "checkPad": "node --import tsx checkPad.ts",
"checkAllPads": "node --import tsx checkAllPads.ts", "checkAllPads": "node --import tsx checkAllPads.ts",
"createUserSession": "node --import tsx createUserSession.ts", "createUserSession": "node --import tsx createUserSession.ts",
@ -33,7 +34,8 @@
"stalePlugins": "node --import tsx ./plugins/stalePlugins.ts", "stalePlugins": "node --import tsx ./plugins/stalePlugins.ts",
"checkPlugin": "node --import tsx ./plugins/checkPlugin.ts", "checkPlugin": "node --import tsx ./plugins/checkPlugin.ts",
"plugins": "node --import tsx ./plugins.ts", "plugins": "node --import tsx ./plugins.ts",
"generateChangelog": "node --import tsx generateReleaseNotes.ts" "generateChangelog": "node --import tsx generateReleaseNotes.ts",
"migrateDB": "node --import tsx migrateDB.ts"
}, },
"author": "", "author": "",
"license": "ISC" "license": "ISC"

View file

@ -23,17 +23,13 @@ const possibleActions = [
] ]
const install = ()=> { const install = ()=> {
const argsAsString: string = args.join(" ");
let registryPlugins: string[] = []; const regexRegistryPlugins = /(?<=i\s)(.*?)(?=--github|--path|$)/;
let localPlugins: string[] = []; const regexLocalPlugins = /(?<=--path\s)(.*?)(?=--github|$)/;
const regexGithubPlugins = /(?<=--github\s)(.*?)(?=--path|$)/;
if (args.indexOf('--path') !== -1) { const registryPlugins = argsAsString.match(regexRegistryPlugins)?.[0]?.split(" ")?.filter(s => s) || [];
const indexToSplit = args.indexOf('--path'); const localPlugins = argsAsString.match(regexLocalPlugins)?.[0]?.split(" ")?.filter(s => s) || [];
registryPlugins = args.slice(1, indexToSplit); const githubPlugins = argsAsString.match(regexGithubPlugins)?.[0]?.split(" ")?.filter(s => s) || [];
localPlugins = args.slice(indexToSplit + 1);
} else {
registryPlugins = args;
}
async function run() { async function run() {
for (const plugin of registryPlugins) { for (const plugin of registryPlugins) {
@ -53,6 +49,11 @@ const install = ()=> {
console.log(`Installing plugin from path: ${plugin}`); console.log(`Installing plugin from path: ${plugin}`);
await linkInstaller.installFromPath(plugin); await linkInstaller.installFromPath(plugin);
} }
for (const plugin of githubPlugins) {
console.log(`Installing plugin from github: ${plugin}`);
await linkInstaller.installFromGitHub(plugin);
}
} }
(async () => { (async () => {

View file

@ -197,7 +197,7 @@ try {
try { try {
console.log('Building documentation...'); console.log('Building documentation...');
run('node ./make_docs.js'); run('pnpm run makeDocs');
console.log('Updating ether.github.com master branch...'); console.log('Updating ether.github.com master branch...');
run('git pull --ff-only', {cwd: '../ether.github.com/'}); run('git pull --ff-only', {cwd: '../ether.github.com/'});
console.log('Committing documentation...'); console.log('Committing documentation...');

View file

@ -39,7 +39,7 @@
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */ "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */

29
doc/cli.md Normal file
View file

@ -0,0 +1,29 @@
# CLI
You can find different tools for migrating things, checking your Etherpad health in the bin directory.
One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.
In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.
````json
{
"dbType": "dirty",
"dbSettings": {
"filename": "./var/rusty.db"
}
}
````
````json
{
"dbType": "rustydb",
"dbSettings": {
"filename": "./var/rusty2.db"
}
}
````
After that we need to move the data from dirty to rustydb.
Therefore, we call `pnpm run migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.

View file

@ -1,6 +1,6 @@
{ {
"devDependencies": { "devDependencies": {
"vitepress": "^1.3.2" "vitepress": "^1.3.4"
}, },
"scripts": { "scripts": {
"docs:dev": "vitepress dev", "docs:dev": "vitepress dev",

View file

@ -9,6 +9,7 @@ services:
build: build:
context: . context: .
args: args:
# Attention: installed plugins in the node_modules folder get overwritten during volume mount in dev
ETHERPAD_PLUGINS: ETHERPAD_PLUGINS:
# change from development to production if needed # change from development to production if needed
target: development target: development

View file

@ -1,55 +0,0 @@
import {exec} from 'child_process'
import fs from 'fs'
import path from 'path'
import pjson from './src/package.json' assert {type: "json"}
const VERSION=pjson.version
console.log(`Building docs for version ${VERSION}`)
const createDirIfNotExists = (dir) => {
if (!fs.existsSync(dir)){
fs.mkdirSync(dir)
}
}
function copyFolderSync(from, to) {
if(fs.existsSync(to)){
const stat = fs.lstatSync(to)
if (stat.isDirectory()){
fs.rmSync(to, { recursive: true })
}
else{
fs.rmSync(to)
}
}
fs.mkdirSync(to);
fs.readdirSync(from).forEach(element => {
if (fs.lstatSync(path.join(from, element)).isFile()) {
fs.copyFileSync(path.join(from, element), path.join(to, element))
} else {
copyFolderSync(path.join(from, element), path.join(to, element))
}
});
}
exec('asciidoctor -v', (err,stdout)=>{
if (err){
console.log('Please install asciidoctor')
console.log('https://asciidoctor.org/docs/install-toolchain/')
process.exit(1)
}
});
createDirIfNotExists('./out')
createDirIfNotExists('./out/doc')
createDirIfNotExists('./out/doc/api')
exec(`asciidoctor -D out/doc doc/index.adoc */**.adoc -a VERSION=${VERSION}`)
exec(`asciidoctor -D out/doc/api ./doc/api/*.adoc -a VERSION=${VERSION}`)
copyFolderSync('./doc/public/', './out/doc/')

View file

@ -30,7 +30,8 @@
"remove-plugins": "pnpm --filter bin run remove-plugins", "remove-plugins": "pnpm --filter bin run remove-plugins",
"list-plugins": "pnpm --filter bin run list-plugins", "list-plugins": "pnpm --filter bin run list-plugins",
"build:etherpad": "pnpm --filter admin run build-copy && pnpm --filter ui run build-copy", "build:etherpad": "pnpm --filter admin run build-copy && pnpm --filter ui run build-copy",
"build:ui": "pnpm --filter ui run build-copy && pnpm --filter admin run build-copy" "build:ui": "pnpm --filter ui run build-copy && pnpm --filter admin run build-copy",
"makeDocs": "pnpm --filter bin run makeDocs"
}, },
"dependencies": { "dependencies": {
"ep_etherpad-lite": "workspace:./src" "ep_etherpad-lite": "workspace:./src"
@ -49,6 +50,6 @@
"type": "git", "type": "git",
"url": "https://github.com/ether/etherpad-lite.git" "url": "https://github.com/ether/etherpad-lite.git"
}, },
"version": "2.2.2", "version": "2.2.3",
"license": "Apache-2.0" "license": "Apache-2.0"
} }

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,10 @@
"admin_plugins_info.version_number": "Нумар вэрсіі", "admin_plugins_info.version_number": "Нумар вэрсіі",
"admin_settings": "Налады", "admin_settings": "Налады",
"admin_settings.current": "Цяперашняя канфігурацыя", "admin_settings.current": "Цяперашняя канфігурацыя",
"admin_settings.current_example-devel": "Прыклад шаблёну наладаў распрацоўкі",
"admin_settings.current_example-prod": "Прыклад шаблёну наладаў вытворчасьці",
"admin_settings.current_restart.value": "Перазапуск Etherpad",
"admin_settings.current_save.value": "Захаваць налады",
"admin_settings.page-title": "Налады — Etherpad", "admin_settings.page-title": "Налады — Etherpad",
"index.newPad": "Стварыць", "index.newPad": "Стварыць",
"index.createOpenPad": "ці тварыць/адкрыць дакумэнт з назвай:", "index.createOpenPad": "ці тварыць/адкрыць дакумэнт з назвай:",
@ -72,6 +76,7 @@
"pad.settings.fontType.normal": "Звычайны", "pad.settings.fontType.normal": "Звычайны",
"pad.settings.language": "Мова:", "pad.settings.language": "Мова:",
"pad.settings.about": "Пра", "pad.settings.about": "Пра",
"pad.settings.poweredBy": "Працуе на",
"pad.importExport.import_export": "Імпарт/Экспарт", "pad.importExport.import_export": "Імпарт/Экспарт",
"pad.importExport.import": "Загрузіжайце любыя тэкставыя файлы або дакумэнты", "pad.importExport.import": "Загрузіжайце любыя тэкставыя файлы або дакумэнты",
"pad.importExport.importSuccessful": "Пасьпяхова!", "pad.importExport.importSuccessful": "Пасьпяхова!",
@ -106,6 +111,9 @@
"pad.modals.corruptPad.cause": "Гэта можа быць выклікана няправільнай канфігурацыяй сэрвэру або іншымі нечаканымі дзеяньнямі. Калі ласка, скантактуйцеся з адміністратарам службы.", "pad.modals.corruptPad.cause": "Гэта можа быць выклікана няправільнай канфігурацыяй сэрвэру або іншымі нечаканымі дзеяньнямі. Калі ласка, скантактуйцеся з адміністратарам службы.",
"pad.modals.deleted": "Выдалены.", "pad.modals.deleted": "Выдалены.",
"pad.modals.deleted.explanation": "Гэты дакумэнт быў выдалены.", "pad.modals.deleted.explanation": "Гэты дакумэнт быў выдалены.",
"pad.modals.rateLimited": "Хуткасьць абмежаваная.",
"pad.modals.rateLimited.explanation": "Вы адаслалі так шмат паведамленьняў, што гэты дакумэнт вас адключыў.",
"pad.modals.rejected.explanation": "Сэрвэр адхіліў паведамленьне, адасланае вашым броўзэрам.",
"pad.modals.disconnected": "Вы былі адключаныя.", "pad.modals.disconnected": "Вы былі адключаныя.",
"pad.modals.disconnected.explanation": "Злучэньне з сэрвэрам было страчанае", "pad.modals.disconnected.explanation": "Злучэньне з сэрвэрам было страчанае",
"pad.modals.disconnected.cause": "Магчыма, сэрвэр недаступны. Калі ласка, паведаміце адміністратару службы, калі праблема будзе паўтарацца.", "pad.modals.disconnected.cause": "Магчыма, сэрвэр недаступны. Калі ласка, паведаміце адміністратару службы, калі праблема будзе паўтарацца.",

View file

@ -3,6 +3,7 @@
"authors": [ "authors": [
"Aefgh39622", "Aefgh39622",
"Andibecker", "Andibecker",
"Ekminarin",
"Patsagorn Y.", "Patsagorn Y.",
"Trisorn Triboon" "Trisorn Triboon"
] ]
@ -121,7 +122,7 @@
"pad.share.readonly": "อ่านเท่านั้น", "pad.share.readonly": "อ่านเท่านั้น",
"pad.share.link": "ลิงก์", "pad.share.link": "ลิงก์",
"pad.share.emebdcode": "URL แบบฝังตัว", "pad.share.emebdcode": "URL แบบฝังตัว",
"pad.chat": "แช", "pad.chat": "แช",
"pad.chat.title": "เปิดการแชทสำหรับแผ่นจดบันทึกนี้", "pad.chat.title": "เปิดการแชทสำหรับแผ่นจดบันทึกนี้",
"pad.chat.loadmessages": "โหลดข้อความเพิ่มเติม", "pad.chat.loadmessages": "โหลดข้อความเพิ่มเติม",
"pad.chat.stick.title": "ปักการสนทนาไว้บนหน้าจอ", "pad.chat.stick.title": "ปักการสนทนาไว้บนหน้าจอ",

View file

@ -19,8 +19,10 @@
* limitations under the License. * limitations under the License.
*/ */
const Changeset = require('../../static/js/Changeset'); import {deserializeOps} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
import {Builder} from "../../static/js/Builder";
import {Attribute} from "../../static/js/types/Attribute";
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const padManager = require('./PadManager'); const padManager = require('./PadManager');
const padMessageHandler = require('../handler/PadMessageHandler'); const padMessageHandler = require('../handler/PadMessageHandler');
@ -563,11 +565,11 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
const oldText = pad.text(); const oldText = pad.text();
atext.text += '\n'; atext.text += '\n';
const eachAttribRun = (attribs: string[], func:Function) => { const eachAttribRun = (attribs: string, func:Function) => {
let textIndex = 0; let textIndex = 0;
const newTextStart = 0; const newTextStart = 0;
const newTextEnd = atext.text.length; const newTextEnd = atext.text.length;
for (const op of Changeset.deserializeOps(attribs)) { for (const op of deserializeOps(attribs)) {
const nextIndex = textIndex + op.chars; const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);
@ -577,10 +579,10 @@ exports.restoreRevision = async (padID: string, rev: number, authorId = '') => {
}; };
// create a new changeset with a helper builder object // create a new changeset with a helper builder object
const builder = Changeset.builder(oldText.length); const builder = new Builder(oldText.length);
// assemble each line into the builder // assemble each line into the builder
eachAttribRun(atext.attribs, (start: number, end: number, attribs:string[]) => { eachAttribRun(atext.attribs, (start: number, end: number, attribs:Attribute[]) => {
builder.insert(atext.text.substring(start, end), attribs); builder.insert(atext.text.substring(start, end), attribs);
}); });

View file

@ -21,8 +21,8 @@
const db = require('./DB'); const db = require('./DB');
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks');
const {randomString, padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); import padutils, {randomString} from "../../static/js/pad_utils";
exports.getColorPalette = () => [ exports.getColorPalette = () => [
'#ffc7c7', '#ffc7c7',
@ -169,7 +169,7 @@ exports.getAuthorId = async (token: string, user: object) => {
* @param {String} token The token * @param {String} token The token
*/ */
exports.getAuthor4Token = async (token: string) => { exports.getAuthor4Token = async (token: string) => {
warnDeprecated( padutils.warnDeprecated(
'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead'); 'AuthorManager.getAuthor4Token() is deprecated; use AuthorManager.getAuthorId() instead');
return await getAuthor4Token(token); return await getAuthor4Token(token);
}; };

View file

@ -20,7 +20,7 @@
*/ */
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const randomString = require('../../static/js/pad_utils').randomString; import {randomString} from "../../static/js/pad_utils";
const db = require('./DB'); const db = require('./DB');
const padManager = require('./PadManager'); const padManager = require('./PadManager');
const sessionManager = require('./SessionManager'); const sessionManager = require('./SessionManager');

View file

@ -7,10 +7,10 @@ import {MapArrayType} from "../types/MapType";
* The pad object, defined with joose * The pad object, defined with joose
*/ */
const AttributeMap = require('../../static/js/AttributeMap'); import AttributeMap from '../../static/js/AttributeMap';
const Changeset = require('../../static/js/Changeset'); import {applyToAText, checkRep, copyAText, deserializeOps, makeAText, makeSplice, opsFromAText, pack, unpack} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
const AttributePool = require('../../static/js/AttributePool'); import AttributePool from '../../static/js/AttributePool';
const Stream = require('../utils/Stream'); const Stream = require('../utils/Stream');
const assert = require('assert').strict; const assert = require('assert').strict;
const db = require('./DB'); const db = require('./DB');
@ -23,8 +23,10 @@ const CustomError = require('../utils/customError');
const readOnlyManager = require('./ReadOnlyManager'); const readOnlyManager = require('./ReadOnlyManager');
const randomString = require('../utils/randomstring'); const randomString = require('../utils/randomstring');
const hooks = require('../../static/js/pluginfw/hooks'); const hooks = require('../../static/js/pluginfw/hooks');
const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils'); import pad_utils from "../../static/js/pad_utils";
const promises = require('../utils/promises'); import {SmartOpAssembler} from "../../static/js/SmartOpAssembler";
import {} from '../utils/promises';
import {timesLimit} from "async";
/** /**
* Copied from the Etherpad source code. It converts Windows line breaks to Unix * Copied from the Etherpad source code. It converts Windows line breaks to Unix
@ -40,7 +42,7 @@ exports.cleanText = (txt:string): string => txt.replace(/\r\n/g, '\n')
class Pad { class Pad {
private db: Database; private db: Database;
private atext: AText; private atext: AText;
private pool: APool; private pool: AttributePool;
private head: number; private head: number;
private chatHead: number; private chatHead: number;
private publicStatus: boolean; private publicStatus: boolean;
@ -56,7 +58,7 @@ class Pad {
*/ */
constructor(id:string, database = db) { constructor(id:string, database = db) {
this.db = database; this.db = database;
this.atext = Changeset.makeAText('\n'); this.atext = makeAText('\n');
this.pool = new AttributePool(); this.pool = new AttributePool();
this.head = -1; this.head = -1;
this.chatHead = -1; this.chatHead = -1;
@ -93,13 +95,13 @@ class Pad {
* @param {String} authorId The id of the author * @param {String} authorId The id of the author
* @return {Promise<number|string>} * @return {Promise<number|string>}
*/ */
async appendRevision(aChangeset:AChangeSet, authorId = '') { async appendRevision(aChangeset:string, authorId = '') {
const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); const newAText = applyToAText(aChangeset, this.atext, this.pool);
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs && if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&
this.head !== -1) { this.head !== -1) {
return this.head; return this.head;
} }
Changeset.copyAText(newAText, this.atext); copyAText(newAText, this.atext);
const newRev = ++this.head; const newRev = ++this.head;
@ -126,11 +128,11 @@ class Pad {
pad: this, pad: this,
authorId, authorId,
get author() { get author() {
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); pad_utils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
return this.authorId; return this.authorId;
}, },
set author(authorId) { set author(authorId) {
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`); pad_utils.warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
this.authorId = authorId; this.authorId = authorId;
}, },
...this.head === 0 ? {} : { ...this.head === 0 ? {} : {
@ -215,7 +217,7 @@ class Pad {
]); ]);
const apool = this.apool(); const apool = this.apool();
let atext = keyAText; let atext = keyAText;
for (const cs of changesets) atext = Changeset.applyToAText(cs, atext, apool); for (const cs of changesets) atext = applyToAText(cs, atext, apool);
return atext; return atext;
} }
@ -293,7 +295,7 @@ class Pad {
(!ins && start > 0 && orig[start - 1] === '\n'); (!ins && start > 0 && orig[start - 1] === '\n');
if (!willEndWithNewline) ins += '\n'; if (!willEndWithNewline) ins += '\n';
if (ndel === 0 && ins.length === 0) return; if (ndel === 0 && ins.length === 0) return;
const changeset = Changeset.makeSplice(orig, start, ndel, ins); const changeset = makeSplice(orig, start, ndel, ins);
await this.appendRevision(changeset, authorId); await this.appendRevision(changeset, authorId);
} }
@ -330,7 +332,7 @@ class Pad {
* @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use * @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use
* `msgOrText.time` instead. * `msgOrText.time` instead.
*/ */
async appendChatMessage(msgOrText: string|typeof ChatMessage, authorId = null, time = null) { async appendChatMessage(msgOrText: string| ChatMessage, authorId = null, time = null) {
const msg = const msg =
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time); msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
this.chatHead++; this.chatHead++;
@ -393,7 +395,7 @@ class Pad {
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = exports.cleanText(context.content); text = exports.cleanText(context.content);
} }
const firstChangeset = Changeset.makeSplice('\n', 0, 0, text); const firstChangeset = makeSplice('\n', 0, 0, text);
await this.appendRevision(firstChangeset, authorId); await this.appendRevision(firstChangeset, authorId);
} }
await hooks.aCallAll('padLoad', {pad: this}); await hooks.aCallAll('padLoad', {pad: this});
@ -437,11 +439,11 @@ class Pad {
// let the plugins know the pad was copied // let the plugins know the pad was copied
await hooks.aCallAll('padCopy', { await hooks.aCallAll('padCopy', {
get originalPad() { get originalPad() {
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); pad_utils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
return this.srcPad; return this.srcPad;
}, },
get destinationID() { get destinationID() {
warnDeprecated( pad_utils.warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead'); 'padCopy destinationID context property is deprecated; use dstPad.id instead');
return this.dstPad.id; return this.dstPad.id;
}, },
@ -520,8 +522,8 @@ class Pad {
const oldAText = this.atext; const oldAText = this.atext;
// based on Changeset.makeSplice // based on Changeset.makeSplice
const assem = Changeset.smartOpAssembler(); const assem = new SmartOpAssembler();
for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); for (const op of opsFromAText(oldAText)) assem.append(op);
assem.endDocument(); assem.endDocument();
// although we have instantiated the dstPad with '\n', an additional '\n' is // although we have instantiated the dstPad with '\n', an additional '\n' is
@ -533,16 +535,16 @@ class Pad {
// create a changeset that removes the previous text and add the newText with // create a changeset that removes the previous text and add the newText with
// all atributes present on the source pad // all atributes present on the source pad
const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); const changeset = pack(oldLength, newLength, assem.toString(), newText);
dstPad.appendRevision(changeset, authorId); dstPad.appendRevision(changeset, authorId);
await hooks.aCallAll('padCopy', { await hooks.aCallAll('padCopy', {
get originalPad() { get originalPad() {
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead'); pad_utils.warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
return this.srcPad; return this.srcPad;
}, },
get destinationID() { get destinationID() {
warnDeprecated( pad_utils.warnDeprecated(
'padCopy destinationID context property is deprecated; use dstPad.id instead'); 'padCopy destinationID context property is deprecated; use dstPad.id instead');
return this.dstPad.id; return this.dstPad.id;
}, },
@ -585,12 +587,14 @@ class Pad {
p.push(db.remove(`pad2readonly:${padID}`)); p.push(db.remove(`pad2readonly:${padID}`));
// delete all chat messages // delete all chat messages
p.push(promises.timesLimit(this.chatHead + 1, 500, async (i: string) => { // @ts-ignore
p.push(timesLimit(this.chatHead + 1, 500, async (i: string) => {
await this.db.remove(`pad:${this.id}:chat:${i}`, null); await this.db.remove(`pad:${this.id}:chat:${i}`, null);
})); }));
// delete all revisions // delete all revisions
p.push(promises.timesLimit(this.head + 1, 500, async (i: string) => { // @ts-ignore
p.push(timesLimit(this.head + 1, 500, async (i: string) => {
await this.db.remove(`pad:${this.id}:revs:${i}`, null); await this.db.remove(`pad:${this.id}:revs:${i}`, null);
})); }));
@ -603,7 +607,7 @@ class Pad {
p.push(padManager.removePad(padID)); p.push(padManager.removePad(padID));
p.push(hooks.aCallAll('padRemove', { p.push(hooks.aCallAll('padRemove', {
get padID() { get padID() {
warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); pad_utils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');
return this.pad.id; return this.pad.id;
}, },
pad: this, pad: this,
@ -706,7 +710,7 @@ class Pad {
} }
}) })
.batch(100).buffer(99); .batch(100).buffer(99);
let atext = Changeset.makeAText('\n'); let atext = makeAText('\n');
for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) { for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) {
try { try {
assert(authorId != null); assert(authorId != null);
@ -717,10 +721,10 @@ class Pad {
assert(timestamp > 0); assert(timestamp > 0);
assert(changeset != null); assert(changeset != null);
assert.equal(typeof changeset, 'string'); assert.equal(typeof changeset, 'string');
Changeset.checkRep(changeset); checkRep(changeset);
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
let text = atext.text; let text = atext.text;
for (const op of Changeset.deserializeOps(unpacked.ops)) { for (const op of deserializeOps(unpacked.ops)) {
if (['=', '-'].includes(op.opcode)) { if (['=', '-'].includes(op.opcode)) {
assert(text.length >= op.chars); assert(text.length >= op.chars);
const consumed = text.slice(0, op.chars); const consumed = text.slice(0, op.chars);
@ -731,7 +735,7 @@ class Pad {
} }
assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString()); assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString());
} }
atext = Changeset.applyToAText(changeset, atext, pool); atext = applyToAText(changeset, atext, pool);
if (isKeyRev) assert.deepEqual(keyAText, atext); if (isKeyRev) assert.deepEqual(keyAText, atext);
} catch (err:any) { } catch (err:any) {
err.message = `(pad ${this.id} revision ${r}) ${err.message}`; err.message = `(pad ${this.id} revision ${r}) ${err.message}`;

View file

@ -22,7 +22,7 @@
import {UserSettingsObject} from "../types/UserSettingsObject"; import {UserSettingsObject} from "../types/UserSettingsObject";
const authorManager = require('./AuthorManager'); const authorManager = require('./AuthorManager');
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks');
const padManager = require('./PadManager'); const padManager = require('./PadManager');
const readOnlyManager = require('./ReadOnlyManager'); const readOnlyManager = require('./ReadOnlyManager');
const sessionManager = require('./SessionManager'); const sessionManager = require('./SessionManager');
@ -30,7 +30,7 @@ const settings = require('../utils/Settings');
const webaccess = require('../hooks/express/webaccess'); const webaccess = require('../hooks/express/webaccess');
const log4js = require('log4js'); const log4js = require('log4js');
const authLogger = log4js.getLogger('auth'); const authLogger = log4js.getLogger('auth');
const {padutils} = require('../../static/js/pad_utils'); import padutils from '../../static/js/pad_utils'
const DENY = Object.freeze({accessStatus: 'deny'}); const DENY = Object.freeze({accessStatus: 'deny'});

View file

@ -21,7 +21,7 @@
*/ */
const CustomError = require('../utils/customError'); const CustomError = require('../utils/customError');
const promises = require('../utils/promises'); import {firstSatisfies} from '../utils/promises';
const randomString = require('../utils/randomstring'); const randomString = require('../utils/randomstring');
const db = require('./DB'); const db = require('./DB');
const groupManager = require('./GroupManager'); const groupManager = require('./GroupManager');
@ -79,7 +79,7 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
groupID: string; groupID: string;
validUntil: number; validUntil: number;
}|null) => (si != null && si.groupID === groupID && now < si.validUntil); }|null) => (si != null && si.groupID === groupID && now < si.validUntil);
const sessionInfo = await promises.firstSatisfies(sessionInfoPromises, isMatch); const sessionInfo = await firstSatisfies(sessionInfoPromises, isMatch) as any;
if (sessionInfo == null) return undefined; if (sessionInfo == null) return undefined;
return sessionInfo.authorID; return sessionInfo.authorID;
}; };

View file

@ -22,7 +22,7 @@
const ejs = require('ejs'); const ejs = require('ejs');
const fs = require('fs'); const fs = require('fs');
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks');
const path = require('path'); const path = require('path');
const resolve = require('resolve'); const resolve = require('resolve');
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');

View file

@ -31,7 +31,7 @@ import os from 'os';
const importHtml = require('../utils/ImportHtml'); const importHtml = require('../utils/ImportHtml');
const importEtherpad = require('../utils/ImportEtherpad'); const importEtherpad = require('../utils/ImportEtherpad');
import log4js from 'log4js'; import log4js from 'log4js';
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks');
const logger = log4js.getLogger('ImportHandler'); const logger = log4js.getLogger('ImportHandler');

View file

@ -21,28 +21,30 @@
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
const AttributeMap = require('../../static/js/AttributeMap'); import AttributeMap from '../../static/js/AttributeMap';
const padManager = require('../db/PadManager'); const padManager = require('../db/PadManager');
const Changeset = require('../../static/js/Changeset'); import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
const ChatMessage = require('../../static/js/ChatMessage'); import ChatMessage from '../../static/js/ChatMessage';
const AttributePool = require('../../static/js/AttributePool'); import AttributePool from '../../static/js/AttributePool';
const AttributeManager = require('../../static/js/AttributeManager'); const AttributeManager = require('../../static/js/AttributeManager');
const authorManager = require('../db/AuthorManager'); const authorManager = require('../db/AuthorManager');
const {padutils} = require('../../static/js/pad_utils'); import padutils from '../../static/js/pad_utils';
const readOnlyManager = require('../db/ReadOnlyManager'); const readOnlyManager = require('../db/ReadOnlyManager');
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
const securityManager = require('../db/SecurityManager'); const securityManager = require('../db/SecurityManager');
const plugins = require('../../static/js/pluginfw/plugin_defs.js'); const plugins = require('../../static/js/pluginfw/plugin_defs');
import log4js from 'log4js'; import log4js from 'log4js';
const messageLogger = log4js.getLogger('message'); const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access'); const accessLogger = log4js.getLogger('access');
const hooks = require('../../static/js/pluginfw/hooks.js'); const hooks = require('../../static/js/pluginfw/hooks');
const stats = require('../stats') const stats = require('../stats')
const assert = require('assert').strict; const assert = require('assert').strict;
import {RateLimiterMemory} from 'rate-limiter-flexible'; import {RateLimiterMemory} from 'rate-limiter-flexible';
import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest"; import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest";
import {APool, AText, PadAuthor, PadType} from "../types/PadType"; import {APool, AText, PadAuthor, PadType} from "../types/PadType";
import {ChangeSet} from "../types/ChangeSet"; import {ChangeSet} from "../types/ChangeSet";
import {ChatMessageMessage, ClientReadyMessage, ClientSaveRevisionMessage, ClientSuggestUserName, ClientUserChangesMessage, ClientVarMessage, CustomMessage, UserNewInfoMessage} from "../../static/js/types/SocketIOMessage";
import {Builder} from "../../static/js/Builder";
const webaccess = require('../hooks/express/webaccess'); const webaccess = require('../hooks/express/webaccess');
const { checkValidRev } = require('../utils/checkValidRev'); const { checkValidRev } = require('../utils/checkValidRev');
@ -214,7 +216,7 @@ exports.handleDisconnect = async (socket:any) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
exports.handleMessage = async (socket:any, message:typeof ChatMessage) => { exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
const env = process.env.NODE_ENV || 'development'; const env = process.env.NODE_ENV || 'development';
if (env === 'production') { if (env === 'production') {
@ -348,15 +350,15 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
stats.counter('pendingEdits').inc(); stats.counter('pendingEdits').inc();
await padChannels.enqueue(thisSession.padId, {socket, message}); await padChannels.enqueue(thisSession.padId, {socket, message});
break; break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message); break; case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message as unknown as UserNewInfoMessage); break;
case 'CHAT_MESSAGE': await handleChatMessage(socket, message); break; case 'CHAT_MESSAGE': await handleChatMessage(socket, message as unknown as ChatMessageMessage); break;
case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break; case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break;
case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message); break; case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message as unknown as ClientSaveRevisionMessage); break;
case 'CLIENT_MESSAGE': { case 'CLIENT_MESSAGE': {
const {type} = message.data.payload; const {type} = message.data.payload;
try { try {
switch (type) { switch (type) {
case 'suggestUserName': handleSuggestUserName(socket, message); break; case 'suggestUserName': handleSuggestUserName(socket, message as unknown as ClientSuggestUserName); break;
default: throw new Error('unknown message type'); default: throw new Error('unknown message type');
} }
} catch (err) { } catch (err) {
@ -384,7 +386,7 @@ exports.handleMessage = async (socket:any, message:typeof ChatMessage) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleSaveRevisionMessage = async (socket:any, message: string) => { const handleSaveRevisionMessage = async (socket:any, message: ClientSaveRevisionMessage) => {
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId); const pad = await padManager.getPad(padId, null, authorId);
await pad.addSavedRevision(pad.head, authorId); await pad.addSavedRevision(pad.head, authorId);
@ -397,7 +399,7 @@ const handleSaveRevisionMessage = async (socket:any, message: string) => {
* @param msg {Object} the message we're sending * @param msg {Object} the message we're sending
* @param sessionID {string} the socketIO session to which we're sending this message * @param sessionID {string} the socketIO session to which we're sending this message
*/ */
exports.handleCustomObjectMessage = (msg: typeof ChatMessage, sessionID: string) => { exports.handleCustomObjectMessage = (msg: CustomMessage, sessionID: string) => {
if (msg.data.type === 'CUSTOM') { if (msg.data.type === 'CUSTOM') {
if (sessionID) { if (sessionID) {
// a sessionID is targeted: directly to this sessionID // a sessionID is targeted: directly to this sessionID
@ -432,7 +434,7 @@ exports.handleCustomMessage = (padID: string, msgString:string) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleChatMessage = async (socket:any, message: typeof ChatMessage) => { const handleChatMessage = async (socket:any, message: ChatMessageMessage) => {
const chatMessage = ChatMessage.fromObject(message.data.message); const chatMessage = ChatMessage.fromObject(message.data.message);
const {padId, author: authorId} = sessioninfos[socket.id]; const {padId, author: authorId} = sessioninfos[socket.id];
// Don't trust the user-supplied values. // Don't trust the user-supplied values.
@ -452,7 +454,7 @@ const handleChatMessage = async (socket:any, message: typeof ChatMessage) => {
* @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message * @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message
* object as the first argument and the destination pad ID as the second argument instead. * object as the first argument and the destination pad ID as the second argument instead.
*/ */
exports.sendChatMessageToPadClients = async (mt: typeof ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => { exports.sendChatMessageToPadClients = async (mt: ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => {
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt); const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
padId = mt instanceof ChatMessage ? puId : padId; padId = mt instanceof ChatMessage ? puId : padId;
const pad = await padManager.getPad(padId, null, message.authorId); const pad = await padManager.getPad(padId, null, message.authorId);
@ -499,7 +501,7 @@ const handleGetChatMessages = async (socket:any, {data: {start, end}}:any) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleSuggestUserName = (socket:any, message: typeof ChatMessage) => { const handleSuggestUserName = (socket:any, message: ClientSuggestUserName) => {
const {newName, unnamedId} = message.data.payload; const {newName, unnamedId} = message.data.payload;
if (newName == null) throw new Error('missing newName'); if (newName == null) throw new Error('missing newName');
if (unnamedId == null) throw new Error('missing unnamedId'); if (unnamedId == null) throw new Error('missing unnamedId');
@ -519,7 +521,7 @@ const handleSuggestUserName = (socket:any, message: typeof ChatMessage) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId}}}: PadUserInfo) => { const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId}}}: UserNewInfoMessage) => {
if (colorId == null) throw new Error('missing colorId'); if (colorId == null) throw new Error('missing colorId');
if (!name) name = null; if (!name) name = null;
const session = sessioninfos[socket.id]; const session = sessioninfos[socket.id];
@ -567,7 +569,9 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleUserChanges = async (socket:any, message: typeof ChatMessage) => { const handleUserChanges = async (socket:any, message: {
data: ClientUserChangesMessage
}) => {
// This one's no longer pending, as we're gonna process it now // This one's no longer pending, as we're gonna process it now
stats.counter('pendingEdits').dec(); stats.counter('pendingEdits').dec();
@ -591,10 +595,10 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
const pad = await padManager.getPad(thisSession.padId, null, thisSession.author); const pad = await padManager.getPad(thisSession.padId, null, thisSession.author);
// Verify that the changeset has valid syntax and is in canonical form // Verify that the changeset has valid syntax and is in canonical form
Changeset.checkRep(changeset); checkRep(changeset);
// Validate all added 'author' attribs to be the same value as the current user // Validate all added 'author' attribs to be the same value as the current user
for (const op of Changeset.deserializeOps(Changeset.unpack(changeset).ops)) { for (const op of deserializeOps(unpack(changeset).ops)) {
// + can add text with attribs // + can add text with attribs
// = can change or add attribs // = can change or add attribs
// - can have attribs, but they are discarded and don't show up in the attribs - // - can have attribs, but they are discarded and don't show up in the attribs -
@ -613,7 +617,7 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
// ex. adoptChangesetAttribs // ex. adoptChangesetAttribs
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
let rebasedChangeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool); let rebasedChangeset = moveOpsToNewPool(changeset, wireApool, pad.pool);
// ex. applyUserChanges // ex. applyUserChanges
let r = baseRev; let r = baseRev;
@ -626,21 +630,21 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r); const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r);
if (changeset === c && thisSession.author === authorId) { if (changeset === c && thisSession.author === authorId) {
// Assume this is a retransmission of an already applied changeset. // Assume this is a retransmission of an already applied changeset.
rebasedChangeset = Changeset.identity(Changeset.unpack(changeset).oldLen); rebasedChangeset = identity(unpack(changeset).oldLen);
} }
// At this point, both "c" (from the pad) and "changeset" (from the // At this point, both "c" (from the pad) and "changeset" (from the
// client) are relative to revision r - 1. The follow function // client) are relative to revision r - 1. The follow function
// rebases "changeset" so that it is relative to revision r // rebases "changeset" so that it is relative to revision r
// and can be applied after "c". // and can be applied after "c".
rebasedChangeset = Changeset.follow(c, rebasedChangeset, false, pad.pool); rebasedChangeset = follow(c, rebasedChangeset, false, pad.pool);
} }
const prevText = pad.text(); const prevText = pad.text();
if (Changeset.oldLen(rebasedChangeset) !== prevText.length) { if (oldLen(rebasedChangeset) !== prevText.length) {
throw new Error( throw new Error(
`Can't apply changeset ${rebasedChangeset} with oldLen ` + `Can't apply changeset ${rebasedChangeset} with oldLen ` +
`${Changeset.oldLen(rebasedChangeset)} to document of length ${prevText.length}`); `${oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
} }
const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author); const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author);
@ -655,7 +659,7 @@ const handleUserChanges = async (socket:any, message: typeof ChatMessage) => {
// Make sure the pad always ends with an empty line. // Make sure the pad always ends with an empty line.
if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) { if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) {
const nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, '\n'); const nlChangeset = makeSplice(pad.text(), pad.text().length - 1, 0, '\n');
await pad.appendRevision(nlChangeset, thisSession.author); await pad.appendRevision(nlChangeset, thisSession.author);
} }
@ -710,7 +714,7 @@ exports.updatePadClients = async (pad: PadType) => {
const revChangeset = revision.changeset; const revChangeset = revision.changeset;
const currentTime = revision.meta.timestamp; const currentTime = revision.meta.timestamp;
const forWire = Changeset.prepareForWire(revChangeset, pad.pool); const forWire = prepareForWire(revChangeset, pad.pool);
const msg = { const msg = {
type: 'COLLABROOM', type: 'COLLABROOM',
data: { data: {
@ -738,14 +742,14 @@ exports.updatePadClients = async (pad: PadType) => {
/** /**
* Copied from the Etherpad Source Code. Don't know what this method does excatly... * Copied from the Etherpad Source Code. Don't know what this method does excatly...
*/ */
const _correctMarkersInPad = (atext: AText, apool: APool) => { const _correctMarkersInPad = (atext: AText, apool: AttributePool) => {
const text = atext.text; const text = atext.text;
// collect char positions of line markers (e.g. bullets) in new atext // collect char positions of line markers (e.g. bullets) in new atext
// that aren't at the start of a line // that aren't at the start of a line
const badMarkers = []; const badMarkers = [];
let offset = 0; let offset = 0;
for (const op of Changeset.deserializeOps(atext.attribs)) { for (const op of deserializeOps(atext.attribs)) {
const attribs = AttributeMap.fromString(op.attribs, apool); const attribs = AttributeMap.fromString(op.attribs, apool);
const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a)); const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a));
if (hasMarker) { if (hasMarker) {
@ -767,7 +771,7 @@ const _correctMarkersInPad = (atext: AText, apool: APool) => {
// create changeset that removes these bad markers // create changeset that removes these bad markers
offset = 0; offset = 0;
const builder = Changeset.builder(text.length); const builder = new Builder(text.length);
badMarkers.forEach((pos) => { badMarkers.forEach((pos) => {
builder.keepText(text.substring(offset, pos)); builder.keepText(text.substring(offset, pos));
@ -785,7 +789,7 @@ const _correctMarkersInPad = (atext: AText, apool: APool) => {
* @param socket the socket.io Socket object for the client * @param socket the socket.io Socket object for the client
* @param message the message from the client * @param message the message from the client
*/ */
const handleClientReady = async (socket:any, message: typeof ChatMessage) => { const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
const sessionInfo = sessioninfos[socket.id]; const sessionInfo = sessioninfos[socket.id];
if (sessionInfo == null) throw new Error('client disconnected'); if (sessionInfo == null) throw new Error('client disconnected');
assert(sessionInfo.author); assert(sessionInfo.author);
@ -793,8 +797,9 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context. await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context.
let {colorId: authorColorId, name: authorName} = message.userInfo || {}; let {colorId: authorColorId, name: authorName} = message.userInfo || {};
if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId)) { if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId as string)) {
messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`); messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`);
// @ts-ignore
authorColorId = null; authorColorId = null;
} }
await Promise.all([ await Promise.all([
@ -872,7 +877,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
const revisionsNeeded = []; const revisionsNeeded = [];
const changesets:MapArrayType<any> = {}; const changesets:MapArrayType<any> = {};
let startNum = message.client_rev + 1; let startNum = message.client_rev! + 1;
let endNum = pad.getHeadRevisionNumber() + 1; let endNum = pad.getHeadRevisionNumber() + 1;
const headNum = pad.getHeadRevisionNumber(); const headNum = pad.getHeadRevisionNumber();
@ -901,7 +906,7 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
// return pending changesets // return pending changesets
for (const r of revisionsNeeded) { for (const r of revisionsNeeded) {
const forWire = Changeset.prepareForWire(changesets[r].changeset, pad.pool); const forWire = prepareForWire(changesets[r].changeset, pad.pool);
const wireMsg = {type: 'COLLABROOM', const wireMsg = {type: 'COLLABROOM',
data: {type: 'CLIENT_RECONNECT', data: {type: 'CLIENT_RECONNECT',
headRev: pad.getHeadRevisionNumber(), headRev: pad.getHeadRevisionNumber(),
@ -926,8 +931,8 @@ const handleClientReady = async (socket:any, message: typeof ChatMessage) => {
let apool; let apool;
// prepare all values for the wire, there's a chance that this throws, if the pad is corrupted // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted
try { try {
atext = Changeset.cloneAText(pad.atext); atext = cloneAText(pad.atext);
const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool); const attribsForWire = prepareForWire(atext.attribs, pad.pool);
apool = attribsForWire.pool.toJsonable(); apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated; atext.attribs = attribsForWire.translated;
} catch (e:any) { } catch (e:any) {
@ -1163,13 +1168,13 @@ const getChangesetInfo = async (pad: PadType, startNum: number, endNum:number, g
if (compositeEnd > endNum || compositeEnd > headRevision + 1) break; if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`]; const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); const backwards = inverse(forwards, lines.textlines, lines.alines, pad.apool());
Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); mutateAttributionLines(forwards, lines.alines, pad.apool());
Changeset.mutateTextLines(forwards, lines.textlines); mutateTextLines(forwards, lines.textlines);
const forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); const forwards2 = moveOpsToNewPool(forwards, pad.apool(), apool);
const backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool); const backwards2 = moveOpsToNewPool(backwards, pad.apool(), apool);
const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1]; const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1];
const t2 = revisionDate[compositeEnd - 1]; const t2 = revisionDate[compositeEnd - 1];
@ -1195,12 +1200,12 @@ const getPadLines = async (pad: PadType, revNum: number) => {
if (revNum >= 0) { if (revNum >= 0) {
atext = await pad.getInternalRevisionAText(revNum); atext = await pad.getInternalRevisionAText(revNum);
} else { } else {
atext = Changeset.makeAText('\n'); atext = makeAText('\n');
} }
return { return {
textlines: Changeset.splitTextLines(atext.text), textlines: splitTextLines(atext.text),
alines: Changeset.splitAttributionLines(atext.attribs, atext.text), alines: splitAttributionLines(atext.attribs, atext.text),
}; };
}; };
@ -1235,7 +1240,7 @@ const composePadChangesets = async (pad: PadType, startNum: number, endNum: numb
for (r = startNum + 1; r < endNum; r++) { for (r = startNum + 1; r < endNum; r++) {
const cs = changesets[r]; const cs = changesets[r];
changeset = Changeset.compose(changeset, cs, pool); changeset = compose(changeset as string, cs as string, pool);
} }
return changeset; return changeset;
} catch (e) { } catch (e) {

View file

@ -324,7 +324,7 @@ exports.expressCreateServer = async (hookName: string, args: any, cb: Function)
// serve index.html under / // serve index.html under /
args.app.get('/', (req: any, res: any) => { args.app.get('/', (req: any, res: any) => {
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "/"+fileNameIndex})); res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex}));
}); });
@ -342,7 +342,7 @@ exports.expressCreateServer = async (hookName: string, args: any, cb: Function)
req, req,
toolbar, toolbar,
isReadOnly, isReadOnly,
entrypoint: "/"+fileNamePad entrypoint: "../"+fileNamePad
}) })
res.send(content); res.send(content);
}); });
@ -356,7 +356,7 @@ exports.expressCreateServer = async (hookName: string, args: any, cb: Function)
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', { res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req, req,
toolbar, toolbar,
entrypoint: "/"+fileNameTimeSlider entrypoint: "../../"+fileNameTimeSlider
})); }));
}); });
} else { } else {

View file

@ -4,11 +4,10 @@ import {MapArrayType} from "../../types/MapType";
import {PartType} from "../../types/PartType"; import {PartType} from "../../types/PartType";
const fs = require('fs').promises; const fs = require('fs').promises;
const minify = require('../../utils/Minify'); import {minify} from '../../utils/Minify';
const path = require('path'); import path from 'node:path';
const plugins = require('../../../static/js/pluginfw/plugin_defs'); const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../utils/Settings'); const settings = require('../../utils/Settings');
import CachingMiddleware from '../../utils/caching_middleware';
// Rewrite tar to include modules with no extensions and proper rooted paths. // Rewrite tar to include modules with no extensions and proper rooted paths.
const getTar = async () => { const getTar = async () => {
@ -32,15 +31,10 @@ const getTar = async () => {
}; };
exports.expressPreSession = async (hookName:string, {app}:any) => { exports.expressPreSession = async (hookName:string, {app}:any) => {
// Cache both minified and static.
const assetCache = new CachingMiddleware();
// Cache static assets
app.all(/\/js\/(.*)/, assetCache.handle.bind(assetCache));
app.all(/\/css\/(.*)/, assetCache.handle.bind(assetCache));
// Minify will serve static files compressed (minify enabled). It also has // Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc. // file-specific hacks for ace/require-kernel/etc.
app.all('/static/:filename(*)', minify.minify); app.all('/static/:filename(*)', minify);
// serve plugin definitions // serve plugin definitions
// not very static, but served here so that client can do // not very static, but served here so that client can do

View file

@ -177,6 +177,10 @@ const checkAccess = async (req:any, res:any, next: Function) => {
res.status(401).send('Authentication Required'); res.status(401).send('Authentication Required');
return; return;
} }
if (ctx.username === '__proto__' || ctx.username === 'constructor' || ctx.username === 'prototype') {
res.end(403);
return;
}
settings.users[ctx.username].username = ctx.username; settings.users[ctx.username].username = ctx.username;
// Make a shallow copy so that the password property can be deleted (to prevent it from // Make a shallow copy so that the password property can be deleted (to prevent it from
// appearing in logs or in the database) without breaking future authentication attempts. // appearing in logs or in the database) without breaking future authentication attempts.

View file

@ -7,8 +7,8 @@ const languages = require('languages4translatewiki');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const _ = require('underscore'); const _ = require('underscore');
const pluginDefs = require('../../static/js/pluginfw/plugin_defs.js'); const pluginDefs = require('../../static/js/pluginfw/plugin_defs');
const existsSync = require('../utils/path_exists'); import existsSync from '../utils/path_exists';
const settings = require('../utils/Settings'); const settings = require('../utils/Settings');
// returns all existing messages merged together and grouped by langcode // returns all existing messages merged together and grouped by langcode

View file

@ -74,7 +74,7 @@ const express = require('./hooks/express');
const hooks = require('../static/js/pluginfw/hooks'); const hooks = require('../static/js/pluginfw/hooks');
const pluginDefs = require('../static/js/pluginfw/plugin_defs'); const pluginDefs = require('../static/js/pluginfw/plugin_defs');
const plugins = require('../static/js/pluginfw/plugins'); const plugins = require('../static/js/pluginfw/plugins');
const {Gate} = require('./utils/promises'); import {Gate} from './utils/promises';
const stats = require('./stats') const stats = require('./stats')
const logger = log4js.getLogger('server'); const logger = log4js.getLogger('server');
@ -100,7 +100,7 @@ const removeSignalListener = (signal: NodeJS.Signals, listener: NodeJS.SignalsLi
}; };
let startDoneGate: { resolve: () => void; } let startDoneGate: Gate<unknown>
exports.start = async () => { exports.start = async () => {
switch (state) { switch (state) {
case State.INITIAL: case State.INITIAL:
@ -181,12 +181,14 @@ exports.start = async () => {
} catch (err) { } catch (err) {
logger.error('Error occurred while starting Etherpad'); logger.error('Error occurred while starting Etherpad');
state = State.STATE_TRANSITION_FAILED; state = State.STATE_TRANSITION_FAILED;
// @ts-ignore
startDoneGate.resolve(); startDoneGate.resolve();
return await exports.exit(err); return await exports.exit(err);
} }
logger.info('Etherpad is running'); logger.info('Etherpad is running');
state = State.RUNNING; state = State.RUNNING;
// @ts-ignore
startDoneGate.resolve(); startDoneGate.resolve();
// Return the HTTP server to make it easier to write tests. // Return the HTTP server to make it easier to write tests.
@ -228,11 +230,13 @@ exports.stop = async () => {
} catch (err) { } catch (err) {
logger.error('Error occurred while stopping Etherpad'); logger.error('Error occurred while stopping Etherpad');
state = State.STATE_TRANSITION_FAILED; state = State.STATE_TRANSITION_FAILED;
// @ts-ignore
stopDoneGate.resolve(); stopDoneGate.resolve();
return await exports.exit(err); return await exports.exit(err);
} }
logger.info('Etherpad stopped'); logger.info('Etherpad stopped');
state = State.STOPPED; state = State.STOPPED;
// @ts-ignore
stopDoneGate.resolve(); stopDoneGate.resolve();
}; };

View file

@ -1,10 +1,11 @@
import {MapArrayType} from "./MapType"; import {MapArrayType} from "./MapType";
import AttributePool from "../../static/js/AttributePool";
export type PadType = { export type PadType = {
id: string, id: string,
apool: ()=>APool, apool: ()=>AttributePool,
atext: AText, atext: AText,
pool: APool, pool: AttributePool,
getInternalRevisionAText: (text:number|string)=>Promise<AText>, getInternalRevisionAText: (text:number|string)=>Promise<AText>,
getValidRevisionRange: (fromRev: string, toRev: string)=>PadRange, getValidRevisionRange: (fromRev: string, toRev: string)=>PadRange,
getRevisionAuthor: (rev: number)=>Promise<string>, getRevisionAuthor: (rev: number)=>Promise<string>,
@ -35,6 +36,7 @@ export type APool = {
clone: ()=>APool, clone: ()=>APool,
check: ()=>Promise<void>, check: ()=>Promise<void>,
eachAttrib: (callback: (key: string, value: any)=>void)=>void, eachAttrib: (callback: (key: string, value: any)=>void)=>void,
getAttrib: (key: number)=>any,
} }

View file

@ -19,8 +19,9 @@
* limitations under the License. * limitations under the License.
*/ */
const AttributeMap = require('../../static/js/AttributeMap'); import AttributeMap from '../../static/js/AttributeMap';
const Changeset = require('../../static/js/Changeset'); import AttributePool from "../../static/js/AttributePool";
import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
const { checkValidRev } = require('./checkValidRev'); const { checkValidRev } = require('./checkValidRev');
/* /*
@ -30,7 +31,7 @@ exports.getPadPlainText = (pad: { getInternalRevisionAText: (arg0: any) => any;
const _analyzeLine = exports._analyzeLine; const _analyzeLine = exports._analyzeLine;
const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext); const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext);
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const apool = pad.pool; const apool = pad.pool;
const pieces = []; const pieces = [];
@ -51,14 +52,14 @@ type LineModel = {
[id:string]:string|number|LineModel [id:string]:string|number|LineModel
} }
exports._analyzeLine = (text:string, aline: LineModel, apool: Function) => { exports._analyzeLine = (text:string, aline: string, apool: AttributePool) => {
const line: LineModel = {}; const line: LineModel = {};
// identify list // identify list
let lineMarker = 0; let lineMarker = 0;
line.listLevel = 0; line.listLevel = 0;
if (aline) { if (aline) {
const [op] = Changeset.deserializeOps(aline); const [op] = deserializeOps(aline);
if (op != null) { if (op != null) {
const attribs = AttributeMap.fromString(op.attribs, apool); const attribs = AttributeMap.fromString(op.attribs, apool);
let listType = attribs.get('list'); let listType = attribs.get('list');
@ -78,7 +79,7 @@ exports._analyzeLine = (text:string, aline: LineModel, apool: Function) => {
} }
if (lineMarker) { if (lineMarker) {
line.text = text.substring(1); line.text = text.substring(1);
line.aline = Changeset.subattribution(aline, 1); line.aline = subattribution(aline, 1);
} else { } else {
line.text = text; line.text = text;
line.aline = aline; line.aline = aline;

View file

@ -18,7 +18,7 @@ import {MapArrayType} from "../types/MapType";
* limitations under the License. * limitations under the License.
*/ */
const Changeset = require('../../static/js/Changeset'); import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
const attributes = require('../../static/js/attributes'); const attributes = require('../../static/js/attributes');
const padManager = require('../db/PadManager'); const padManager = require('../db/PadManager');
const _ = require('underscore'); const _ = require('underscore');
@ -27,7 +27,9 @@ const hooks = require('../../static/js/pluginfw/hooks');
const eejs = require('../eejs'); const eejs = require('../eejs');
const _analyzeLine = require('./ExportHelper')._analyzeLine; const _analyzeLine = require('./ExportHelper')._analyzeLine;
const _encodeWhitespace = require('./ExportHelper')._encodeWhitespace; const _encodeWhitespace = require('./ExportHelper')._encodeWhitespace;
const padutils = require('../../static/js/pad_utils').padutils; import padutils from "../../static/js/pad_utils";
import {StringIterator} from "../../static/js/StringIterator";
import {StringAssembler} from "../../static/js/StringAssembler";
const getPadHTML = async (pad: PadType, revNum: string) => { const getPadHTML = async (pad: PadType, revNum: string) => {
let atext = pad.atext; let atext = pad.atext;
@ -44,7 +46,7 @@ const getPadHTML = async (pad: PadType, revNum: string) => {
const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string[]) => { const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string[]) => {
const apool = pad.apool(); const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const tags = ['h1', 'h2', 'strong', 'em', 'u', 's']; const tags = ['h1', 'h2', 'strong', 'em', 'u', 's'];
const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];
@ -80,6 +82,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
css += '<style>\n'; css += '<style>\n';
for (const a of Object.keys(apool.numToAttrib)) { for (const a of Object.keys(apool.numToAttrib)) {
// @ts-ignore
const attr = apool.numToAttrib[a]; const attr = apool.numToAttrib[a];
// skip non author attributes // skip non author attributes
@ -115,6 +118,7 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// see hook exportHtmlAdditionalTagsWithData // see hook exportHtmlAdditionalTagsWithData
attrib = propName; attrib = propName;
} }
// @ts-ignore
const propTrueNum = apool.putAttrib(attrib, true); const propTrueNum = apool.putAttrib(attrib, true);
if (propTrueNum >= 0) { if (propTrueNum >= 0) {
anumMap[propTrueNum] = i; anumMap[propTrueNum] = i;
@ -127,8 +131,8 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
// becomes // becomes
// <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
const taker = Changeset.stringIterator(text); const taker = new StringIterator(text);
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
const openTags:string[] = []; const openTags:string[] = [];
const getSpanClassFor = (i: string) => { const getSpanClassFor = (i: string) => {
@ -204,7 +208,8 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
return; return;
} }
const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); // @ts-ignore
const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));
idx += numChars; idx += numChars;
// this iterates over every op string and decides which tags to open or to close // this iterates over every op string and decides which tags to open or to close

View file

@ -22,7 +22,9 @@
import {AText, PadType} from "../types/PadType"; import {AText, PadType} from "../types/PadType";
import {MapType} from "../types/MapType"; import {MapType} from "../types/MapType";
const Changeset = require('../../static/js/Changeset'); import {deserializeOps, splitAttributionLines, subattribution} from '../../static/js/Changeset';
import {StringIterator} from "../../static/js/StringIterator";
import {StringAssembler} from "../../static/js/StringAssembler";
const attributes = require('../../static/js/attributes'); const attributes = require('../../static/js/attributes');
const padManager = require('../db/PadManager'); const padManager = require('../db/PadManager');
const _analyzeLine = require('./ExportHelper')._analyzeLine; const _analyzeLine = require('./ExportHelper')._analyzeLine;
@ -45,13 +47,14 @@ const getPadTXT = async (pad: PadType, revNum: string) => {
const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => { const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
const apool = pad.apool(); const apool = pad.apool();
const textLines = atext.text.slice(0, -1).split('\n'); const textLines = atext.text.slice(0, -1).split('\n');
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); const attribLines = splitAttributionLines(atext.attribs, atext.text);
const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; const props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough'];
const anumMap: MapType = {}; const anumMap: MapType = {};
const css = ''; const css = '';
props.forEach((propName, i) => { props.forEach((propName, i) => {
// @ts-ignore
const propTrueNum = apool.putAttrib([propName, true], true); const propTrueNum = apool.putAttrib([propName, true], true);
if (propTrueNum >= 0) { if (propTrueNum >= 0) {
anumMap[propTrueNum] = i; anumMap[propTrueNum] = i;
@ -69,8 +72,8 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
// <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
// becomes // becomes
// <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i> // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
const taker = Changeset.stringIterator(text); const taker = new StringIterator(text);
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
let idx = 0; let idx = 0;
@ -79,7 +82,7 @@ const getTXTFromAtext = (pad: PadType, atext: AText, authorColors?:string) => {
return; return;
} }
const ops = Changeset.deserializeOps(Changeset.subattribution(attribs, idx, idx + numChars)); const ops = deserializeOps(subattribution(attribs, idx, idx + numChars));
idx += numChars; idx += numChars;
for (const o of ops) { for (const o of ops) {

View file

@ -18,7 +18,7 @@ import {APool} from "../types/PadType";
* limitations under the License. * limitations under the License.
*/ */
const AttributePool = require('../../static/js/AttributePool'); import AttributePool from '../../static/js/AttributePool';
const {Pad} = require('../db/Pad'); const {Pad} = require('../db/Pad');
const Stream = require('./Stream'); const Stream = require('./Stream');
const authorManager = require('../db/AuthorManager'); const authorManager = require('../db/AuthorManager');
@ -61,7 +61,7 @@ exports.setPadRaw = async (padId: string, r: string, authorId = '') => {
try { try {
const processRecord = async (key:string, value: null|{ const processRecord = async (key:string, value: null|{
padIDs: string|Record<string, unknown>, padIDs: string|Record<string, unknown>,
pool: APool pool: AttributePool
}) => { }) => {
if (!value) return; if (!value) return;
const keyParts = key.split(':'); const keyParts = key.split(':');

View file

@ -16,10 +16,11 @@
*/ */
import log4js from 'log4js'; import log4js from 'log4js';
const Changeset = require('../../static/js/Changeset'); import {deserializeOps} from '../../static/js/Changeset';
const contentcollector = require('../../static/js/contentcollector'); const contentcollector = require('../../static/js/contentcollector');
import jsdom from 'jsdom'; import jsdom from 'jsdom';
import {PadType} from "../types/PadType"; import {PadType} from "../types/PadType";
import {Builder} from "../../static/js/Builder";
const apiLogger = log4js.getLogger('ImportHtml'); const apiLogger = log4js.getLogger('ImportHtml');
let processor:any; let processor:any;
@ -69,13 +70,13 @@ exports.setPadHTML = async (pad: PadType, html:string, authorId = '') => {
const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`; const newAttribs = `${result.lineAttribs.join('|1+1')}|1+1`;
// create a new changeset with a helper builder object // create a new changeset with a helper builder object
const builder = Changeset.builder(1); const builder = new Builder(1);
// assemble each line into the builder // assemble each line into the builder
let textIndex = 0; let textIndex = 0;
const newTextStart = 0; const newTextStart = 0;
const newTextEnd = newText.length; const newTextEnd = newText.length;
for (const op of Changeset.deserializeOps(newAttribs)) { for (const op of deserializeOps(newAttribs)) {
const nextIndex = textIndex + op.chars; const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
const start = Math.max(newTextStart, textIndex); const start = Math.max(newTextStart, textIndex);

View file

@ -21,20 +21,20 @@
* limitations under the License. * limitations under the License.
*/ */
const settings = require('./Settings'); import {TransformResult} from "esbuild";
const fs = require('fs').promises; import mime from 'mime-types';
const path = require('path'); import log4js from 'log4js';
const plugins = require('../../static/js/pluginfw/plugin_defs'); import {compressCSS, compressJS} from './MinifyWorker'
const mime = require('mime-types');
const Threads = require('threads');
const log4js = require('log4js');
const sanitizePathname = require('./sanitizePathname');
const settings = require('./Settings');
import {promises as fs} from 'fs';
import path from 'node:path';
const plugins = require('../../static/js/pluginfw/plugin_defs');
import sanitizePathname from './sanitizePathname';
const logger = log4js.getLogger('Minify'); const logger = log4js.getLogger('Minify');
const ROOT_DIR = path.join(settings.root, 'src/static/'); const ROOT_DIR = path.join(settings.root, 'src/static/');
const threadsPool = new Threads.Pool(() => Threads.spawn(new Threads.Worker('./MinifyWorker')), 2);
const LIBRARY_WHITELIST = [ const LIBRARY_WHITELIST = [
'async', 'async',
@ -48,10 +48,10 @@ const LIBRARY_WHITELIST = [
// What follows is a terrible hack to avoid loop-back within the server. // What follows is a terrible hack to avoid loop-back within the server.
// TODO: Serve files from another service, or directly from the file system. // TODO: Serve files from another service, or directly from the file system.
const requestURI = async (url, method, headers) => { const requestURI = async (url: string | URL, method: any, headers: { [x: string]: any; }) => {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
let status = 500; let status = 500;
const content = []; const content: any[] = [];
const mockRequest = { const mockRequest = {
url, url,
method, method,
@ -61,7 +61,7 @@ const requestURI = async (url, method, headers) => {
let mockResponse; let mockResponse;
const p = new Promise((resolve) => { const p = new Promise((resolve) => {
mockResponse = { mockResponse = {
writeHead: (_status, _headers) => { writeHead: (_status: number, _headers: { [x: string]: any; }) => {
status = _status; status = _status;
for (const header in _headers) { for (const header in _headers) {
if (Object.prototype.hasOwnProperty.call(_headers, header)) { if (Object.prototype.hasOwnProperty.call(_headers, header)) {
@ -69,37 +69,63 @@ const requestURI = async (url, method, headers) => {
} }
} }
}, },
setHeader: (header, value) => { setHeader: (header: string, value: { toString: () => any; }) => {
headers[header.toLowerCase()] = value.toString(); headers[header.toLowerCase()] = value.toString();
}, },
header: (header, value) => { header: (header: string, value: { toString: () => any; }) => {
headers[header.toLowerCase()] = value.toString(); headers[header.toLowerCase()] = value.toString();
}, },
write: (_content) => { write: (_content: any) => {
_content && content.push(_content); _content && content.push(_content);
}, },
end: (_content) => { end: (_content: any) => {
_content && content.push(_content); _content && content.push(_content);
resolve([status, headers, content.join('')]); resolve([status, headers, content.join('')]);
}, },
}; };
}); });
await minify(mockRequest, mockResponse); await _minify(mockRequest, mockResponse);
return await p; return await p;
}; };
const requestURIs = (locations, method, headers, callback) => { const _requestURIs = (locations: any[], method: any, headers: {
[x: string]:
/**
* This Module manages all /minified/* requests. It controls the
* minification && compression of Javascript and CSS.
*/
/*
* 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
any;
}, callback: (arg0: any[], arg1: any[], arg2: any[]) => void) => {
Promise.all(locations.map(async (loc) => { Promise.all(locations.map(async (loc) => {
try { try {
return await requestURI(loc, method, headers); return await requestURI(loc, method, headers);
} catch (err) { } catch (err) {
logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` + logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` +
`${JSON.stringify(headers)}) failed: ${err.stack || err}`); // @ts-ignore
`${JSON.stringify(headers)}) failed: ${err.stack || err}`);
return [500, headers, '']; return [500, headers, ''];
} }
})).then((responses) => { })).then((responses) => {
// @ts-ignore
const statuss = responses.map((x) => x[0]); const statuss = responses.map((x) => x[0]);
// @ts-ignore
const headerss = responses.map((x) => x[1]); const headerss = responses.map((x) => x[1]);
// @ts-ignore
const contentss = responses.map((x) => x[2]); const contentss = responses.map((x) => x[2]);
callback(statuss, headerss, contentss); callback(statuss, headerss, contentss);
}); });
@ -119,11 +145,12 @@ const compatPaths = {
* @param req the Express request * @param req the Express request
* @param res the Express response * @param res the Express response
*/ */
const minify = async (req, res) => { const _minify = async (req:any, res:any) => {
let filename = req.params.filename; let filename = req.params.filename;
try { try {
filename = sanitizePathname(filename); filename = sanitizePathname(filename);
} catch (err) { } catch (err) {
// @ts-ignore
logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`); logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`);
res.writeHead(404, {}); res.writeHead(404, {});
res.end(); res.end();
@ -131,6 +158,7 @@ const minify = async (req, res) => {
} }
// Backward compatibility for plugins that require() files from old paths. // Backward compatibility for plugins that require() files from old paths.
// @ts-ignore
const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')]; const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')];
if (newLocation != null) { if (newLocation != null) {
logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`); logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`);
@ -193,7 +221,7 @@ const minify = async (req, res) => {
res.writeHead(200, {}); res.writeHead(200, {});
res.end(); res.end();
} else if (req.method === 'GET') { } else if (req.method === 'GET') {
const content = await getFileCompressed(filename, contentType); const content = await getFileCompressed(filename, contentType as string);
res.header('Content-Type', contentType); res.header('Content-Type', contentType);
res.writeHead(200, {}); res.writeHead(200, {});
res.write(content); res.write(content);
@ -205,7 +233,7 @@ const minify = async (req, res) => {
}; };
// Check for the existance of the file and get the last modification date. // Check for the existance of the file and get the last modification date.
const statFile = async (filename, dirStatLimit) => { const statFile = async (filename: string, dirStatLimit: number):Promise<(any | boolean)[]> => {
/* /*
* The only external call to this function provides an explicit value for * The only external call to this function provides an explicit value for
* dirStatLimit: this check could be removed. * dirStatLimit: this check could be removed.
@ -221,6 +249,7 @@ const statFile = async (filename, dirStatLimit) => {
try { try {
stats = await fs.stat(path.resolve(ROOT_DIR, filename)); stats = await fs.stat(path.resolve(ROOT_DIR, filename));
} catch (err) { } catch (err) {
// @ts-ignore
if (['ENOENT', 'ENOTDIR'].includes(err.code)) { if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
// Stat the directory instead. // Stat the directory instead.
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1); const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
@ -234,69 +263,64 @@ const statFile = async (filename, dirStatLimit) => {
let contentCache = new Map(); let contentCache = new Map();
const getFileCompressed = async (filename, contentType) => { const getFileCompressed = async (filename: any, contentType: string) => {
if (contentCache.has(filename)) { if (contentCache.has(filename)) {
return contentCache.get(filename); return contentCache.get(filename);
} }
let content = await getFile(filename); let content: Buffer|string = await getFile(filename);
if (!content || !settings.minify) { if (!content || !settings.minify) {
return content; return content;
} else if (contentType === 'application/javascript') { } else if (contentType === 'application/javascript') {
return await new Promise((resolve) => { return await new Promise(async (resolve) => {
threadsPool.queue(async ({compressJS}) => { try {
logger.info('Compress JS file %s.', filename);
content = content.toString();
try { try {
logger.info('Compress JS file %s.', filename); let compressResult: TransformResult<{ minify: boolean }>
compressResult = await compressJS(content);
content = content.toString(); content = compressResult.code.toString(); // Convert content obj code to string
const compressResult = await compressJS(content);
if (compressResult.error) {
console.error(`Error compressing JS (${filename}) using terser`, compressResult.error);
} else {
content = compressResult.code.toString(); // Convert content obj code to string
}
} catch (error) { } catch (error) {
console.error('getFile() returned an error in ' + console.error(`Error compressing JS (${filename}) using esbuild`, error);
`getFileCompressed(${filename}, ${contentType}): ${error}`);
} }
contentCache.set(filename, content); } catch (error) {
resolve(content); console.error('getFile() returned an error in ' +
}); `getFileCompressed(${filename}, ${contentType}): ${error}`);
}
contentCache.set(filename, content);
resolve(content);
}); });
} else if (contentType === 'text/css') { } else if (contentType === 'text/css') {
return await new Promise((resolve) => { return await new Promise(async (resolve) => {
threadsPool.queue(async ({compressCSS}) => { try {
logger.info('Compress CSS file %s.', filename);
try { try {
logger.info('Compress CSS file %s.', filename); content = await compressCSS(path.resolve(ROOT_DIR, filename));
const compressResult = await compressCSS(path.resolve(ROOT_DIR,filename));
if (compressResult.error) {
console.error(`Error compressing CSS (${filename}) using terser`, compressResult.error);
} else {
content = compressResult
}
} catch (error) { } catch (error) {
console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`); console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`);
} }
contentCache.set(filename, content); contentCache.set(filename, content);
resolve(content); resolve(content);
}); } catch (e) {
}); console.error('getFile() returned an error in ' +
`getFileCompressed(${filename}, ${contentType}): ${e}`);
}
})
} else { } else {
contentCache.set(filename, content); contentCache.set(filename, content);
return content; return content;
} }
}; };
const getFile = async (filename) => { const getFile = async (filename: any) => {
return await fs.readFile(path.resolve(ROOT_DIR, filename)); return await fs.readFile(path.resolve(ROOT_DIR, filename));
}; };
exports.minify = (req, res, next) => minify(req, res).catch((err) => next(err || new Error(err))); export const minify = (req:any, res:any, next:Function) => _minify(req, res).catch((err) => next(err || new Error(err)));
exports.requestURIs = requestURIs; export const requestURIs = _requestURIs;
exports.shutdown = async (hookName, context) => { export const shutdown = async (hookName: string, context:any) => {
await threadsPool.terminate(); contentCache = new Map();
}; };

View file

@ -3,14 +3,13 @@
* Worker thread to minify JS & CSS files out of the main NodeJS thread * Worker thread to minify JS & CSS files out of the main NodeJS thread
*/ */
import {expose} from 'threads'
import {build, transform} from 'esbuild'; import {build, transform} from 'esbuild';
/* /*
* Minify JS content * Minify JS content
* @param {string} content - JS content to minify * @param {string} content - JS content to minify
*/ */
const compressJS = async (content) => { export const compressJS = async (content: string) => {
return await transform(content, {minify: true}); return await transform(content, {minify: true});
} }
@ -19,7 +18,7 @@ const compressJS = async (content) => {
* @param {string} filename - name of the file * @param {string} filename - name of the file
* @param {string} ROOT_DIR - the root dir of Etherpad * @param {string} ROOT_DIR - the root dir of Etherpad
*/ */
const compressCSS = async (content) => { export const compressCSS = async (content: string) => {
const transformedCSS = await build( const transformedCSS = await build(
{ {
entryPoints: [content], entryPoints: [content],
@ -41,8 +40,3 @@ const compressCSS = async (content) => {
) )
return transformedCSS.outputFiles[0].text return transformedCSS.outputFiles[0].text
}; };
expose({
compressJS: compressJS,
compressCSS,
});

View file

@ -169,11 +169,11 @@ exports.authenticationMethod = 'sso'
/* /*
* The Type of the database * The Type of the database
*/ */
exports.dbType = 'dirty'; exports.dbType = 'rustydb';
/** /**
* This setting is passed with dbType to ueberDB to set up the database * This setting is passed with dbType to ueberDB to set up the database
*/ */
exports.dbSettings = {filename: path.join(exports.root, 'var/dirty.db')}; exports.dbSettings = {filename: path.join(exports.root, 'var/rusty.db')};
/** /**
* The default Text of a new pad * The default Text of a new pad
@ -837,7 +837,7 @@ exports.reloadSettings = () => {
exports.skinName = 'colibris'; exports.skinName = 'colibris';
} }
if (!exports.socketTransportProtocols.includes("websocket") || exports.socketTransportProtocols.includes("polling")) { if (!exports.socketTransportProtocols.includes("websocket") || !exports.socketTransportProtocols.includes("polling")) {
logger.warn("Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling']."); logger.warn("Invalid socketTransportProtocols setting. Please check out settings.json.template and update your settings.json. Falling back to the default ['websocket', 'polling'].");
exports.socketTransportProtocols = ['websocket', 'polling']; exports.socketTransportProtocols = ['websocket', 'polling'];
} }
@ -941,6 +941,11 @@ exports.reloadSettings = () => {
logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`); logger.warn(`${dirtyWarning} File location: ${exports.dbSettings.filename}`);
} }
if (exports.dbType === 'rustydb') {
exports.dbSettings.filename = absolutePaths.makeAbsolute(exports.dbSettings.filename);
logger.warn(`File location: ${exports.dbSettings.filename}`);
}
if (exports.ip === '') { if (exports.ip === '') {
// using Unix socket for connectivity // using Unix socket for connectivity
logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' + logger.warn('The settings file contains an empty string ("") for the "ip" parameter. The ' +

View file

@ -1,211 +0,0 @@
'use strict';
/*
* 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Buffer} from 'node:buffer'
import fs from 'fs';
const fsp = fs.promises;
import path from 'path';
import zlib from 'zlib';
const settings = require('./Settings');
const existsSync = require('./path_exists');
import util from 'util';
/*
* The crypto module can be absent on reduced node installations.
*
* Here we copy the approach TypeScript guys used for https://github.com/microsoft/TypeScript/issues/19100
* If importing crypto fails at runtime, we replace sha256 with djb2, which is
* weaker, but works for our case.
*
* djb2 was written in 1991 by Daniel J. Bernstein.
*
*/
import _crypto from 'crypto';
let CACHE_DIR: string|undefined = path.join(settings.root, 'var/');
CACHE_DIR = existsSync(CACHE_DIR) ? CACHE_DIR : undefined;
type Headers = {
[id: string]: string
}
type ResponseCache = {
[id: string]: {
statusCode: number
headers: Headers
}
}
const responseCache: ResponseCache = {};
const djb2Hash = (data: string) => {
const chars = data.split('').map((str) => str.charCodeAt(0));
return `${chars.reduce((prev, curr) => ((prev << 5) + prev) + curr, 5381)}`;
};
const generateCacheKeyWithSha256 =
(path: string) => _crypto.createHash('sha256').update(path).digest('hex');
const generateCacheKeyWithDjb2 =
(path: string) => Buffer.from(djb2Hash(path)).toString('hex');
let generateCacheKey: (path: string)=>string;
if (_crypto) {
generateCacheKey = generateCacheKeyWithSha256;
} else {
generateCacheKey = generateCacheKeyWithDjb2;
console.warn('No crypto support in this nodejs runtime. Djb2 (weaker) will be used.');
}
// MIMIC https://github.com/microsoft/TypeScript/commit/9677b0641cc5ba7d8b701b4f892ed7e54ceaee9a - END
/*
This caches and compresses 200 and 404 responses to GET and HEAD requests.
TODO: Caching and compressing are solved problems, a middleware configuration
should replace this.
*/
export default class CachingMiddleware {
handle(req: any, res: any, next: any) {
this._handle(req, res, next).catch((err) => next(err || new Error(err)));
}
async _handle(req: any, res: any, next: any) {
if (!(req.method === 'GET' || req.method === 'HEAD') || !CACHE_DIR) {
return next(undefined, req, res);
}
const oldReq:ResponseCache = {};
const oldRes:ResponseCache = {};
const supportsGzip =
(req.get('Accept-Encoding') || '').indexOf('gzip') !== -1;
const url = new URL(req.url, 'http://localhost');
const cacheKey = generateCacheKey(url.pathname + url.search);
const stats = await fsp.stat(`${CACHE_DIR}minified_${cacheKey}`).catch(() => {});
const modifiedSince =
req.headers['if-modified-since'] && new Date(req.headers['if-modified-since']);
if (stats != null && stats.mtime && responseCache[cacheKey]) {
req.headers['if-modified-since'] = stats.mtime.toUTCString();
} else {
delete req.headers['if-modified-since'];
}
// Always issue get to downstream.
oldReq.method = req.method;
req.method = 'GET';
// This handles read/write synchronization as well as its predecessor,
// which is to say, not at all.
// TODO: Implement locking on write or ditch caching of gzip and use
// existing middlewares.
const respond = () => {
req.method = oldReq.method || req.method;
res.write = oldRes.write || res.write;
res.end = oldRes.end || res.end;
const headers: Headers = {};
Object.assign(headers, (responseCache[cacheKey].headers || {}));
const statusCode = responseCache[cacheKey].statusCode;
let pathStr = `${CACHE_DIR}minified_${cacheKey}`;
if (supportsGzip && /application\/javascript/.test(headers['content-type'])) {
pathStr += '.gz';
headers['content-encoding'] = 'gzip';
}
const lastModified = headers['last-modified'] && new Date(headers['last-modified']);
if (statusCode === 200 && lastModified <= modifiedSince) {
res.writeHead(304, headers);
res.end();
} else if (req.method === 'GET') {
const readStream = fs.createReadStream(pathStr);
res.writeHead(statusCode, headers);
readStream.pipe(res);
} else {
res.writeHead(statusCode, headers);
res.end();
}
};
const expirationDate = new Date(((responseCache[cacheKey] || {}).headers || {}).expires);
if (expirationDate > new Date()) {
// Our cached version is still valid.
return respond();
}
const _headers:Headers = {};
oldRes.setHeader = res.setHeader;
res.setHeader = (key: string, value: string) => {
// Don't set cookies, see issue #707
if (key.toLowerCase() === 'set-cookie') return;
_headers[key.toLowerCase()] = value;
// @ts-ignore
oldRes.setHeader.call(res, key, value);
};
oldRes.writeHead = res.writeHead;
res.writeHead = (status: number, headers: Headers) => {
res.writeHead = oldRes.writeHead;
if (status === 200) {
// Update cache
let buffer = '';
Object.keys(headers || {}).forEach((key) => {
res.setHeader(key, headers[key]);
});
headers = _headers;
oldRes.write = res.write;
oldRes.end = res.end;
res.write = (data: number, encoding: number) => {
buffer += data.toString(encoding);
};
res.end = async (data: number, encoding: number) => {
await Promise.all([
fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}`, buffer).catch(() => {}),
util.promisify(zlib.gzip)(buffer)
// @ts-ignore
.then((content: string) => fsp.writeFile(`${CACHE_DIR}minified_${cacheKey}.gz`, content))
.catch(() => {}),
]);
responseCache[cacheKey] = {statusCode: status, headers};
respond();
};
} else if (status === 304) {
// Nothing new changed from the cached version.
oldRes.write = res.write;
oldRes.end = res.end;
res.write = (data: number, encoding: number) => {};
res.end = (data: number, encoding: number) => { respond(); };
} else {
res.writeHead(status, headers);
}
};
next(undefined, req, res);
}
};

View file

@ -3,8 +3,13 @@
import {PadAuthor, PadType} from "../types/PadType"; import {PadAuthor, PadType} from "../types/PadType";
import {MapArrayType} from "../types/MapType"; import {MapArrayType} from "../types/MapType";
const AttributeMap = require('../../static/js/AttributeMap'); import AttributeMap from '../../static/js/AttributeMap';
const Changeset = require('../../static/js/Changeset'); import {applyToAText, checkRep, compose, deserializeOps, pack, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
import {Builder} from "../../static/js/Builder";
import {OpAssembler} from "../../static/js/OpAssembler";
import {numToString} from "../../static/js/ChangesetUtils";
import Op from "../../static/js/Op";
import {StringAssembler} from "../../static/js/StringAssembler";
const attributes = require('../../static/js/attributes'); const attributes = require('../../static/js/attributes');
const exportHtml = require('./ExportHtml'); const exportHtml = require('./ExportHtml');
@ -33,7 +38,7 @@ class PadDiff {
} }
_isClearAuthorship(changeset: any){ _isClearAuthorship(changeset: any){
// unpack // unpack
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
// check if there is nothing in the charBank // check if there is nothing in the charBank
if (unpacked.charBank !== '') { if (unpacked.charBank !== '') {
@ -45,7 +50,7 @@ class PadDiff {
return false; return false;
} }
const [clearOperator, anotherOp] = Changeset.deserializeOps(unpacked.ops); const [clearOperator, anotherOp] = deserializeOps(unpacked.ops);
// check if there is only one operator // check if there is only one operator
if (anotherOp != null) return false; if (anotherOp != null) return false;
@ -78,7 +83,7 @@ class PadDiff {
const atext = await this._pad.getInternalRevisionAText(rev); const atext = await this._pad.getInternalRevisionAText(rev);
// build clearAuthorship changeset // build clearAuthorship changeset
const builder = Changeset.builder(atext.text.length); const builder = new Builder(atext.text.length);
builder.keepText(atext.text, [['author', '']], this._pad.pool); builder.keepText(atext.text, [['author', '']], this._pad.pool);
const changeset = builder.toString(); const changeset = builder.toString();
@ -93,7 +98,7 @@ class PadDiff {
const changeset = await this._createClearAuthorship(rev); const changeset = await this._createClearAuthorship(rev);
// apply the clearAuthorship changeset // apply the clearAuthorship changeset
const newAText = Changeset.applyToAText(changeset, atext, this._pad.pool); const newAText = applyToAText(changeset, atext, this._pad.pool);
return newAText; return newAText;
} }
@ -157,7 +162,7 @@ class PadDiff {
if (superChangeset == null) { if (superChangeset == null) {
superChangeset = changeset; superChangeset = changeset;
} else { } else {
superChangeset = Changeset.compose(superChangeset, changeset, this._pad.pool); superChangeset = compose(superChangeset, changeset, this._pad.pool);
} }
} }
@ -171,10 +176,10 @@ class PadDiff {
const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool); const deletionChangeset = this._createDeletionChangeset(superChangeset, atext, this._pad.pool);
// apply the superChangeset, which includes all addings // apply the superChangeset, which includes all addings
atext = Changeset.applyToAText(superChangeset, atext, this._pad.pool); atext = applyToAText(superChangeset, atext, this._pad.pool);
// apply the deletionChangeset, which adds a deletions // apply the deletionChangeset, which adds a deletions
atext = Changeset.applyToAText(deletionChangeset, atext, this._pad.pool); atext = applyToAText(deletionChangeset, atext, this._pad.pool);
} }
return atext; return atext;
@ -209,22 +214,22 @@ class PadDiff {
_extendChangesetWithAuthor(changeset: any, author: any, apool: any){ _extendChangesetWithAuthor(changeset: any, author: any, apool: any){
// unpack // unpack
const unpacked = Changeset.unpack(changeset); const unpacked = unpack(changeset);
const assem = Changeset.opAssembler(); const assem = new OpAssembler();
// create deleted attribs // create deleted attribs
const authorAttrib = apool.putAttrib(['author', author || '']); const authorAttrib = apool.putAttrib(['author', author || '']);
const deletedAttrib = apool.putAttrib(['removed', true]); const deletedAttrib = apool.putAttrib(['removed', true]);
const attribs = `*${Changeset.numToString(authorAttrib)}*${Changeset.numToString(deletedAttrib)}`; const attribs = `*${numToString(authorAttrib)}*${numToString(deletedAttrib)}`;
for (const operator of Changeset.deserializeOps(unpacked.ops)) { for (const operator of deserializeOps(unpacked.ops)) {
if (operator.opcode === '-') { if (operator.opcode === '-') {
// this is a delete operator, extend it with the author // this is a delete operator, extend it with the author
operator.attribs = attribs; operator.attribs = attribs;
} else if (operator.opcode === '=' && operator.attribs) { } else if (operator.opcode === '=' && operator.attribs) {
// this is operator changes only attributes, let's mark which author did that // this is operator changes only attributes, let's mark which author did that
operator.attribs += `*${Changeset.numToString(authorAttrib)}`; operator.attribs += `*${numToString(authorAttrib)}`;
} }
// append the new operator to our assembler // append the new operator to our assembler
@ -232,26 +237,31 @@ class PadDiff {
} }
// return the modified changeset // return the modified changeset
return Changeset.pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank); return pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank);
} }
_createDeletionChangeset(cs: any, startAText: any, apool: any){ _createDeletionChangeset(cs: any, startAText: any, apool: any){
const lines = Changeset.splitTextLines(startAText.text); const lines = splitTextLines(startAText.text);
const alines = Changeset.splitAttributionLines(startAText.attribs, startAText.text); const alines = splitAttributionLines(startAText.attribs, startAText.text);
// lines and alines are what the exports is meant to apply to. // lines and alines are what the exports is meant to apply to.
// They may be arrays or objects with .get(i) and .length methods. // They may be arrays or objects with .get(i) and .length methods.
// They include final newlines on lines. // They include final newlines on lines.
const linesGet = (idx: number) => { const linesGet = (idx: number) => {
// @ts-ignore
if (lines.get) { if (lines.get) {
// @ts-ignore
return lines.get(idx); return lines.get(idx);
} else { } else {
// @ts-ignore
return lines[idx]; return lines[idx];
} }
}; };
const aLinesGet = (idx: number) => { const aLinesGet = (idx: number) => {
// @ts-ignore
if (alines.get) { if (alines.get) {
// @ts-ignore
return alines.get(idx); return alines.get(idx);
} else { } else {
return alines[idx]; return alines[idx];
@ -263,14 +273,14 @@ class PadDiff {
let curLineOps: { next: () => any; } | null = null; let curLineOps: { next: () => any; } | null = null;
let curLineOpsNext: { done: any; value: any; } | null = null; let curLineOpsNext: { done: any; value: any; } | null = null;
let curLineOpsLine: number; let curLineOpsLine: number;
let curLineNextOp = new Changeset.Op('+'); let curLineNextOp = new Op('+');
const unpacked = Changeset.unpack(cs); const unpacked = unpack(cs);
const builder = Changeset.builder(unpacked.newLen); const builder = new Builder(unpacked.newLen);
const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => { const consumeAttribRuns = (numChars: number, func: Function /* (len, attribs, endsLine)*/) => {
if (!curLineOps || curLineOpsLine !== curLine) { if (!curLineOps || curLineOpsLine !== curLine) {
curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); curLineOps = deserializeOps(aLinesGet(curLine));
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
curLineOpsLine = curLine; curLineOpsLine = curLine;
let indexIntoLine = 0; let indexIntoLine = 0;
@ -291,13 +301,13 @@ class PadDiff {
curChar = 0; curChar = 0;
curLineOpsLine = curLine; curLineOpsLine = curLine;
curLineNextOp.chars = 0; curLineNextOp.chars = 0;
curLineOps = Changeset.deserializeOps(aLinesGet(curLine)); curLineOps = deserializeOps(aLinesGet(curLine));
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
} }
if (!curLineNextOp.chars) { if (!curLineNextOp.chars) {
if (curLineOpsNext!.done) { if (curLineOpsNext!.done) {
curLineNextOp = new Changeset.Op(); curLineNextOp = new Op();
} else { } else {
curLineNextOp = curLineOpsNext!.value; curLineNextOp = curLineOpsNext!.value;
curLineOpsNext = curLineOps!.next(); curLineOpsNext = curLineOps!.next();
@ -332,7 +342,7 @@ class PadDiff {
const nextText = (numChars: number) => { const nextText = (numChars: number) => {
let len = 0; let len = 0;
const assem = Changeset.stringAssembler(); const assem = new StringAssembler();
const firstString = linesGet(curLine).substring(curChar); const firstString = linesGet(curLine).substring(curChar);
len += firstString.length; len += firstString.length;
assem.append(firstString); assem.append(firstString);
@ -360,7 +370,7 @@ class PadDiff {
}; };
}; };
for (const csOp of Changeset.deserializeOps(unpacked.ops)) { for (const csOp of deserializeOps(unpacked.ops)) {
if (csOp.opcode === '=') { if (csOp.opcode === '=') {
const textBank = nextText(csOp.chars); const textBank = nextText(csOp.chars);
@ -442,7 +452,7 @@ class PadDiff {
} }
} }
return Changeset.checkRep(builder.toString()); return checkRep(builder.toString());
} }
} }
@ -450,6 +460,7 @@ class PadDiff {
// this method is 80% like Changeset.inverse. I just changed so instead of reverting, // this method is 80% like Changeset.inverse. I just changed so instead of reverting,
// it adds deletions and attribute changes to the atext. // it adds deletions and attribute changes to the atext.
// @ts-ignore
PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) { PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
}; };

View file

@ -1,5 +1,5 @@
'use strict'; 'use strict';
const fs = require('fs'); import fs from 'node:fs';
const check = (path:string) => { const check = (path:string) => {
const existsSync = fs.statSync || fs.existsSync; const existsSync = fs.statSync || fs.existsSync;
@ -13,4 +13,4 @@ const check = (path:string) => {
return result; return result;
}; };
module.exports = check; export default check;

View file

@ -7,7 +7,7 @@
// `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if // `predicate`. Resolves to `undefined` if none of the Promises satisfy `predicate`, or if
// `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as // `promises` is empty. If `predicate` is nullish, the truthiness of the resolved value is used as
// the predicate. // the predicate.
exports.firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => { export const firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) => {
if (predicate == null) { if (predicate == null) {
predicate = (x: any) => x; predicate = (x: any) => x;
} }
@ -44,7 +44,7 @@ exports.firstSatisfies = <T>(promises: Promise<T>[], predicate: null|Function) =
// `total` is greater than `concurrency`, then `concurrency` Promises will be created right away, // `total` is greater than `concurrency`, then `concurrency` Promises will be created right away,
// and each remaining Promise will be created once one of the earlier Promises resolves.) This async // and each remaining Promise will be created once one of the earlier Promises resolves.) This async
// function resolves once all `total` Promises have resolved. // function resolves once all `total` Promises have resolved.
exports.timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => { export const timesLimit = async (total: number, concurrency: number, promiseCreator: Function) => {
if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive'); if (total > 0 && concurrency <= 0) throw new RangeError('concurrency must be positive');
let next = 0; let next = 0;
const addAnother = () => promiseCreator(next++).finally(() => { const addAnother = () => promiseCreator(next++).finally(() => {
@ -61,7 +61,7 @@ exports.timesLimit = async (total: number, concurrency: number, promiseCreator:
* An ordinary Promise except the `resolve` and `reject` executor functions are exposed as * An ordinary Promise except the `resolve` and `reject` executor functions are exposed as
* properties. * properties.
*/ */
class Gate<T> extends Promise<T> { export class Gate<T> extends Promise<T> {
// Coax `.then()` into returning an ordinary Promise, not a Gate. See // Coax `.then()` into returning an ordinary Promise, not a Gate. See
// https://stackoverflow.com/a/65669070 for the rationale. // https://stackoverflow.com/a/65669070 for the rationale.
static get [Symbol.species]() { return Promise; } static get [Symbol.species]() { return Promise; }
@ -75,4 +75,3 @@ class Gate<T> extends Promise<T> {
Object.assign(this, props); Object.assign(this, props);
} }
} }
exports.Gate = Gate;

View file

@ -1,10 +1,8 @@
'use strict'; import path from 'path';
const path = require('path');
// Normalizes p and ensures that it is a relative path that does not reach outside. See // Normalizes p and ensures that it is a relative path that does not reach outside. See
// https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context. // https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context.
module.exports = (p: string, pathApi = path) => { const sanitizeRoot = (p: string, pathApi = path) => {
// The documentation for path.normalize() says that it resolves '..' and '.' segments. The word // The documentation for path.normalize() says that it resolves '..' and '.' segments. The word
// "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might // "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might
// not be the same thing as 'b'. Most path normalization functions from other libraries (e.g., // not be the same thing as 'b'. Most path normalization functions from other libraries (e.g.,
@ -21,3 +19,5 @@ module.exports = (p: string, pathApi = path) => {
if (pathApi.sep === '\\') p = p.replace(/\\/g, '/'); if (pathApi.sep === '\\') p = p.replace(/\\/g, '/');
return p; return p;
}; };
export default sanitizeRoot

View file

@ -30,23 +30,23 @@
} }
], ],
"dependencies": { "dependencies": {
"@etherpad/express-session": "^1.18.2", "@etherpad/express-session": "^1.18.4",
"async": "^3.2.5", "async": "^3.2.6",
"axios": "^1.7.3", "axios": "^1.7.7",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"esbuild": "^0.23.0", "esbuild": "^0.23.1",
"express": "4.19.2", "express": "4.19.2",
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"find-root": "1.1.0", "find-root": "1.1.0",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"jose": "^5.6.3", "jose": "^5.8.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsdom": "^24.1.1", "jsdom": "^25.0.0",
"jsonminify": "0.4.2", "jsonminify": "0.4.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"languages4translatewiki": "0.1.3", "languages4translatewiki": "0.1.3",
@ -67,11 +67,10 @@
"semver": "^7.6.3", "semver": "^7.6.3",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"superagent": "9.0.2", "superagent": "10.1.0",
"threads": "^1.7.0",
"tinycon": "0.6.8", "tinycon": "0.6.8",
"tsx": "4.17.0", "tsx": "4.19.0",
"ueberdb2": "^4.2.92", "ueberdb2": "^4.2.100",
"underscore": "1.13.7", "underscore": "1.13.7",
"unorm": "1.6.0", "unorm": "1.6.0",
"wtfnode": "^0.9.3" "wtfnode": "^0.9.3"
@ -81,26 +80,28 @@
"etherpad-lite": "node/server.ts" "etherpad-lite": "node/server.ts"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.46.0", "@playwright/test": "^1.46.1",
"@types/async": "^3.2.24", "@types/async": "^3.2.24",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/formidable": "^3.4.5", "@types/formidable": "^3.4.5",
"@types/http-errors": "^2.0.4", "@types/http-errors": "^2.0.4",
"@types/jquery": "^3.5.30", "@types/jquery": "^3.5.30",
"@types/js-cookie": "^3.0.6",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/mime-types": "^2.1.4",
"@types/mocha": "^10.0.7", "@types/mocha": "^10.0.7",
"@types/node": "^22.1.0", "@types/node": "^22.5.4",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.2",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/sinon": "^17.0.3", "@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/underscore": "^1.11.15", "@types/underscore": "^1.11.15",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"eslint": "^9.8.0", "eslint": "^9.9.1",
"eslint-config-etherpad": "^4.0.4", "eslint-config-etherpad": "^4.0.4",
"etherpad-cli-client": "^3.0.2", "etherpad-cli-client": "^3.0.2",
"mocha": "^10.7.0", "mocha": "^10.7.3",
"mocha-froth": "^0.2.10", "mocha-froth": "^0.2.10",
"nodeify": "^1.0.1", "nodeify": "^1.0.1",
"openapi-schema-validation": "^0.4.2", "openapi-schema-validation": "^0.4.2",
@ -108,7 +109,9 @@
"sinon": "^18.0.0", "sinon": "^18.0.0",
"split-grid": "^1.0.11", "split-grid": "^1.0.11",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.5.4" "typescript": "^5.5.4",
"vitest": "^2.0.5",
"rusty-store-kv": "^1.1.4"
}, },
"engines": { "engines": {
"node": ">=18.18.2", "node": ">=18.18.2",
@ -132,8 +135,9 @@
"test-ui:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs --ui", "test-ui:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs --ui",
"test-admin": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --workers 1", "test-admin": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --workers 1",
"test-admin:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --ui --workers 1", "test-admin:ui": "cross-env NODE_ENV=production npx playwright test tests/frontend-new/admin-spec --ui --workers 1",
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts" "debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
}, },
"version": "2.2.2", "version": "2.2.3",
"license": "Apache-2.0" "license": "Apache-2.0"
} }

View file

@ -1,10 +1,10 @@
'use strict'; // @ts-nocheck
import AttributeMap from './AttributeMap';
const AttributeMap = require('./AttributeMap'); import {compose, deserializeOps, isIdentity} from './Changeset';
const Changeset = require('./Changeset'); import {Builder} from "./Builder";
const ChangesetUtils = require('./ChangesetUtils'); import {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils';
const attributes = require('./attributes'); import attributes from './attributes';
const underscore = require("underscore") import underscore from "underscore";
const lineMarkerAttribute = 'lmkr'; const lineMarkerAttribute = 'lmkr';
@ -51,7 +51,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
if (!this.applyChangesetCallback) return changeset; if (!this.applyChangesetCallback) return changeset;
const cs = changeset.toString(); const cs = changeset.toString();
if (!Changeset.isIdentity(cs)) { if (!isIdentity(cs)) {
this.applyChangesetCallback(cs); this.applyChangesetCallback(cs);
} }
@ -85,7 +85,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
// as the range might not be continuous // as the range might not be continuous
// due to the presence of line markers on the rows // due to the presence of line markers on the rows
if (allChangesets) { if (allChangesets) {
allChangesets = Changeset.compose( allChangesets = compose(
allChangesets.toString(), rowChangeset.toString(), this.rep.apool); allChangesets.toString(), rowChangeset.toString(), this.rep.apool);
} else { } else {
allChangesets = rowChangeset; allChangesets = rowChangeset;
@ -125,9 +125,9 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
* @param attribs an array of attributes * @param attribs an array of attributes
*/ */
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) { _setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
const builder = Changeset.builder(this.rep.lines.totalWidth()); const builder = new Builder(this.rep.lines.totalWidth());
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); buildKeepToStartOfRange(this.rep, builder, [row, startCol]);
ChangesetUtils.buildKeepRange( buildKeepRange(
this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool);
return builder; return builder;
}, },
@ -150,7 +150,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
// get `attributeName` attribute of first char of line // get `attributeName` attribute of first char of line
const aline = this.rep.alines[lineNum]; const aline = this.rep.alines[lineNum];
if (!aline) return ''; if (!aline) return '';
const [op] = Changeset.deserializeOps(aline); const [op] = deserializeOps(aline);
if (op == null) return ''; if (op == null) return '';
return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || ''; return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || '';
}, },
@ -163,7 +163,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
// get attributes of first char of line // get attributes of first char of line
const aline = this.rep.alines[lineNum]; const aline = this.rep.alines[lineNum];
if (!aline) return []; if (!aline) return [];
const [op] = Changeset.deserializeOps(aline); const [op] = deserializeOps(aline);
if (op == null) return []; if (op == null) return [];
return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; return [...attributes.attribsFromString(op.attribs, this.rep.apool)];
}, },
@ -221,7 +221,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
let hasAttrib = true; let hasAttrib = true;
let indexIntoLine = 0; let indexIntoLine = 0;
for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { for (const op of deserializeOps(rep.alines[lineNum])) {
const opStartInLine = indexIntoLine; const opStartInLine = indexIntoLine;
const opEndInLine = opStartInLine + op.chars; const opEndInLine = opStartInLine + op.chars;
if (!hasIt(op.attribs)) { if (!hasIt(op.attribs)) {
@ -258,7 +258,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
// we need to sum up how much characters each operations take until the wanted position // we need to sum up how much characters each operations take until the wanted position
let currentPointer = 0; let currentPointer = 0;
for (const currentOperation of Changeset.deserializeOps(aline)) { for (const currentOperation of deserializeOps(aline)) {
currentPointer += currentOperation.chars; currentPointer += currentOperation.chars;
if (currentPointer <= column) continue; if (currentPointer <= column) continue;
return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)]; return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)];
@ -285,13 +285,13 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
*/ */
setAttributeOnLine(lineNum, attributeName, attributeValue) { setAttributeOnLine(lineNum, attributeName, attributeValue) {
let loc = [0, 0]; let loc = [0, 0];
const builder = Changeset.builder(this.rep.lines.totalWidth()); const builder = new Builder(this.rep.lines.totalWidth());
const hasMarker = this.lineHasMarker(lineNum); const hasMarker = this.lineHasMarker(lineNum);
ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0]));
if (hasMarker) { if (hasMarker) {
ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [
[attributeName, attributeValue], [attributeName, attributeValue],
], this.rep.apool); ], this.rep.apool);
} else { } else {
@ -314,7 +314,7 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
* @param attributeValue if given only attributes with equal value will be removed * @param attributeValue if given only attributes with equal value will be removed
*/ */
removeAttributeOnLine(lineNum, attributeName, attributeValue) { removeAttributeOnLine(lineNum, attributeName, attributeValue) {
const builder = Changeset.builder(this.rep.lines.totalWidth()); const builder = new Builder(this.rep.lines.totalWidth());
const hasMarker = this.lineHasMarker(lineNum); const hasMarker = this.lineHasMarker(lineNum);
let found = false; let found = false;
@ -333,16 +333,16 @@ AttributeManager.prototype = underscore.default(AttributeManager.prototype).exte
return; return;
} }
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);
const countAttribsWithMarker = underscore.chain(attribs).filter((a) => !!a[1]) const countAttribsWithMarker = underscore.chain(attribs).filter((a) => !!a[1])
.map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value();
// if we have marker and any of attributes don't need to have marker. we need delete it // if we have marker and any of attributes don't need to have marker. we need delete it
if (hasMarker && !countAttribsWithMarker) { if (hasMarker && !countAttribsWithMarker) {
ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]);
} else { } else {
ChangesetUtils.buildKeepRange( buildKeepRange(
this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool);
} }

View file

@ -1,6 +1,9 @@
'use strict'; 'use strict';
const attributes = require('./attributes'); import AttributePool from "./AttributePool";
import {Attribute} from "./types/Attribute";
import attributes from './attributes';
/** /**
* A `[key, value]` pair of strings describing a text attribute. * A `[key, value]` pair of strings describing a text attribute.
@ -21,6 +24,7 @@ const attributes = require('./attributes');
* Convenience class to convert an Op's attribute string to/from a Map of key, value pairs. * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs.
*/ */
class AttributeMap extends Map { class AttributeMap extends Map {
private readonly pool? : AttributePool|null
/** /**
* Converts an attribute string into an AttributeMap. * Converts an attribute string into an AttributeMap.
* *
@ -28,14 +32,14 @@ class AttributeMap extends Map {
* @param {AttributePool} pool - Attribute pool. * @param {AttributePool} pool - Attribute pool.
* @returns {AttributeMap} * @returns {AttributeMap}
*/ */
static fromString(str, pool) { public static fromString(str: string, pool?: AttributePool|null): AttributeMap {
return new AttributeMap(pool).updateFromString(str); return new AttributeMap(pool).updateFromString(str);
} }
/** /**
* @param {AttributePool} pool - Attribute pool. * @param {AttributePool} pool - Attribute pool.
*/ */
constructor(pool) { constructor(pool?: AttributePool|null) {
super(); super();
/** @public */ /** @public */
this.pool = pool; this.pool = pool;
@ -46,15 +50,15 @@ class AttributeMap extends Map {
* @param {string} v - Attribute value. * @param {string} v - Attribute value.
* @returns {AttributeMap} `this` (for chaining). * @returns {AttributeMap} `this` (for chaining).
*/ */
set(k, v) { set(k: string, v: string):this {
k = k == null ? '' : String(k); k = k == null ? '' : String(k);
v = v == null ? '' : String(v); v = v == null ? '' : String(v);
this.pool.putAttrib([k, v]); this.pool!.putAttrib([k, v]);
return super.set(k, v); return super.set(k, v);
} }
toString() { toString() {
return attributes.attribsToString(attributes.sort([...this]), this.pool); return attributes.attribsToString(attributes.sort([...this]), this.pool!);
} }
/** /**
@ -63,7 +67,7 @@ class AttributeMap extends Map {
* key is removed from this map (if present). * key is removed from this map (if present).
* @returns {AttributeMap} `this` (for chaining). * @returns {AttributeMap} `this` (for chaining).
*/ */
update(entries, emptyValueIsDelete = false) { update(entries: Iterable<Attribute>, emptyValueIsDelete: boolean = false): AttributeMap {
for (let [k, v] of entries) { for (let [k, v] of entries) {
k = k == null ? '' : String(k); k = k == null ? '' : String(k);
v = v == null ? '' : String(v); v = v == null ? '' : String(v);
@ -83,9 +87,9 @@ class AttributeMap extends Map {
* key is removed from this map (if present). * key is removed from this map (if present).
* @returns {AttributeMap} `this` (for chaining). * @returns {AttributeMap} `this` (for chaining).
*/ */
updateFromString(str, emptyValueIsDelete = false) { updateFromString(str: string, emptyValueIsDelete: boolean = false): AttributeMap {
return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete); return this.update(attributes.attribsFromString(str, this.pool!), emptyValueIsDelete);
} }
} }
module.exports = AttributeMap; export default AttributeMap

View file

@ -44,6 +44,8 @@
* @property {number} nextNum - The attribute ID to assign to the next new attribute. * @property {number} nextNum - The attribute ID to assign to the next new attribute.
*/ */
import {Attribute} from "./types/Attribute";
/** /**
* Represents an attribute pool, which is a collection of attributes (pairs of key and value * Represents an attribute pool, which is a collection of attributes (pairs of key and value
* strings) along with their identifiers (non-negative integers). * strings) along with their identifiers (non-negative integers).
@ -55,6 +57,14 @@
* in the pad. * in the pad.
*/ */
class AttributePool { class AttributePool {
numToAttrib: {
[key: number]: [string, string]
}
private attribToNum: {
[key: number]: [string, string]
}
private nextNum: number
constructor() { constructor() {
/** /**
* Maps an attribute identifier to the attribute's `[key, value]` string pair. * Maps an attribute identifier to the attribute's `[key, value]` string pair.
@ -96,7 +106,10 @@ class AttributePool {
*/ */
clone() { clone() {
const c = new AttributePool(); const c = new AttributePool();
for (const [n, a] of Object.entries(this.numToAttrib)) c.numToAttrib[n] = [a[0], a[1]]; for (const [n, a] of Object.entries(this.numToAttrib)){
// @ts-ignore
c.numToAttrib[n] = [a[0], a[1]];
}
Object.assign(c.attribToNum, this.attribToNum); Object.assign(c.attribToNum, this.attribToNum);
c.nextNum = this.nextNum; c.nextNum = this.nextNum;
return c; return c;
@ -111,15 +124,17 @@ class AttributePool {
* membership in the pool without mutating the pool. * membership in the pool without mutating the pool.
* @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool. * @returns {number} The attribute's identifier, or -1 if the attribute is not in the pool.
*/ */
putAttrib(attrib, dontAddIfAbsent = false) { putAttrib(attrib: Attribute, dontAddIfAbsent = false) {
const str = String(attrib); const str = String(attrib);
if (str in this.attribToNum) { if (str in this.attribToNum) {
// @ts-ignore
return this.attribToNum[str]; return this.attribToNum[str];
} }
if (dontAddIfAbsent) { if (dontAddIfAbsent) {
return -1; return -1;
} }
const num = this.nextNum++; const num = this.nextNum++;
// @ts-ignore
this.attribToNum[str] = num; this.attribToNum[str] = num;
this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')]; this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')];
return num; return num;
@ -130,7 +145,7 @@ class AttributePool {
* @returns {Attribute} The attribute with the given identifier, or nullish if there is no such * @returns {Attribute} The attribute with the given identifier, or nullish if there is no such
* attribute. * attribute.
*/ */
getAttrib(num) { getAttrib(num: number): Attribute {
const pair = this.numToAttrib[num]; const pair = this.numToAttrib[num];
if (!pair) { if (!pair) {
return pair; return pair;
@ -143,7 +158,7 @@ class AttributePool {
* @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty * @returns {string} Eqivalent to `getAttrib(num)[0]` if the attribute exists, otherwise the empty
* string. * string.
*/ */
getAttribKey(num) { getAttribKey(num: number): string {
const pair = this.numToAttrib[num]; const pair = this.numToAttrib[num];
if (!pair) return ''; if (!pair) return '';
return pair[0]; return pair[0];
@ -154,7 +169,7 @@ class AttributePool {
* @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty * @returns {string} Eqivalent to `getAttrib(num)[1]` if the attribute exists, otherwise the empty
* string. * string.
*/ */
getAttribValue(num) { getAttribValue(num: number) {
const pair = this.numToAttrib[num]; const pair = this.numToAttrib[num];
if (!pair) return ''; if (!pair) return '';
return pair[1]; return pair[1];
@ -166,8 +181,8 @@ class AttributePool {
* @param {Function} func - Callback to call with two arguments: key and value. Its return value * @param {Function} func - Callback to call with two arguments: key and value. Its return value
* is ignored. * is ignored.
*/ */
eachAttrib(func) { eachAttrib(func: (k: string, v: string)=>void) {
for (const n of Object.keys(this.numToAttrib)) { for (const n in this.numToAttrib) {
const pair = this.numToAttrib[n]; const pair = this.numToAttrib[n];
func(pair[0], pair[1]); func(pair[0], pair[1]);
} }
@ -196,11 +211,12 @@ class AttributePool {
* `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared * `new AttributePool().fromJsonable(pool.toJsonable())` to copy because the resulting shared
* state will lead to pool corruption. * state will lead to pool corruption.
*/ */
fromJsonable(obj) { fromJsonable(obj: this) {
this.numToAttrib = obj.numToAttrib; this.numToAttrib = obj.numToAttrib;
this.nextNum = obj.nextNum; this.nextNum = obj.nextNum;
this.attribToNum = {}; this.attribToNum = {};
for (const n of Object.keys(this.numToAttrib)) { for (const n of Object.keys(this.numToAttrib)) {
// @ts-ignore
this.attribToNum[String(this.numToAttrib[n])] = Number(n); this.attribToNum[String(this.numToAttrib[n])] = Number(n);
} }
return this; return this;
@ -213,6 +229,7 @@ class AttributePool {
if (!Number.isInteger(this.nextNum)) throw new Error('nextNum property is not an integer'); if (!Number.isInteger(this.nextNum)) throw new Error('nextNum property is not an integer');
if (this.nextNum < 0) throw new Error('nextNum property is negative'); if (this.nextNum < 0) throw new Error('nextNum property is negative');
for (const prop of ['numToAttrib', 'attribToNum']) { for (const prop of ['numToAttrib', 'attribToNum']) {
// @ts-ignore
const obj = this[prop]; const obj = this[prop];
if (obj == null) throw new Error(`${prop} property is null`); if (obj == null) throw new Error(`${prop} property is null`);
if (typeof obj !== 'object') throw new TypeError(`${prop} property is not an object`); if (typeof obj !== 'object') throw new TypeError(`${prop} property is not an object`);
@ -231,9 +248,10 @@ class AttributePool {
if (v == null) throw new TypeError(`attrib ${i} value is null`); if (v == null) throw new TypeError(`attrib ${i} value is null`);
if (typeof v !== 'string') throw new TypeError(`attrib ${i} value is not a string`); if (typeof v !== 'string') throw new TypeError(`attrib ${i} value is not a string`);
const attrStr = String(attr); const attrStr = String(attr);
// @ts-ignore
if (this.attribToNum[attrStr] !== i) throw new Error(`attribToNum for ${attrStr} !== ${i}`); if (this.attribToNum[attrStr] !== i) throw new Error(`attribToNum for ${attrStr} !== ${i}`);
} }
} }
} }
module.exports = AttributePool; export default AttributePool

108
src/static/js/Builder.ts Normal file
View file

@ -0,0 +1,108 @@
/**
* Incrementally builds a Changeset.
*
* @typedef {object} Builder
* @property {Function} insert -
* @property {Function} keep -
* @property {Function} keepText -
* @property {Function} remove -
* @property {Function} toString -
*/
import {SmartOpAssembler} from "./SmartOpAssembler";
import Op from "./Op";
import {StringAssembler} from "./StringAssembler";
import AttributeMap from "./AttributeMap";
import {Attribute} from "./types/Attribute";
import AttributePool from "./AttributePool";
import {opsFromText, pack} from "./Changeset";
/**
* @param {number} oldLen - Old length
* @returns {Builder}
*/
export class Builder {
private readonly oldLen: number;
private assem: SmartOpAssembler;
private readonly o: Op;
private charBank: StringAssembler;
constructor(oldLen: number) {
this.oldLen = oldLen
this.assem = new SmartOpAssembler()
this.o = new Op()
this.charBank = new StringAssembler()
}
/**
* @param {number} N - Number of characters to keep.
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
* character must be a newline.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
keep = (N: number, L?: number, attribs?: string|Attribute[], pool?: AttributePool): Builder => {
this.o.opcode = '=';
this.o.attribs = typeof attribs === 'string'
? attribs : new AttributeMap(pool).update(attribs || []).toString();
this.o.chars = N;
this.o.lines = (L || 0);
this.assem.append(this.o);
return this;
}
/**
* @param {string} text - Text to keep.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
keepText= (text: string, attribs?: string|Attribute[], pool?: AttributePool): Builder=> {
for (const op of opsFromText('=', text, attribs, pool)) this.assem.append(op);
return this;
}
/**
* @param {string} text - Text to insert.
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
* (no pool needed in latter case).
* @param {?AttributePool.ts} pool - Attribute pool, only required if `attribs` is a list of
* attribute key, value pairs.
* @returns {Builder} this
*/
insert= (text: string, attribs: string | Attribute[] | undefined, pool?: AttributePool | null | undefined): Builder => {
for (const op of opsFromText('+', text, attribs, pool)) this.assem.append(op);
this.charBank.append(text);
return this;
}
/**
* @param {number} N - Number of characters to remove.
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
* character must be a newline.
* @returns {Builder} this
*/
remove= (N: number, L?: number): Builder => {
this.o.opcode = '-';
this.o.attribs = '';
this.o.chars = N;
this.o.lines = (L || 0);
this.assem.append(this.o);
return this;
}
toString= () => {
this.assem.endDocument();
const newLen = this.oldLen + this.assem.getLengthChange();
return pack(this.oldLen, newLen, this.assem.toString(), this.charBank.toString());
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,12 @@
* based on a SkipList * based on a SkipList
*/ */
import {RepModel} from "./types/RepModel";
import {ChangeSetBuilder} from "./types/ChangeSetBuilder";
import {Attribute} from "./types/Attribute";
import AttributePool from "./AttributePool";
import {Builder} from "./Builder";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -20,7 +26,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
exports.buildRemoveRange = (rep, builder, start, end) => { export const buildRemoveRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number,number], end: [number, number]) => {
const startLineOffset = rep.lines.offsetOfIndex(start[0]); const startLineOffset = rep.lines.offsetOfIndex(start[0]);
const endLineOffset = rep.lines.offsetOfIndex(end[0]); const endLineOffset = rep.lines.offsetOfIndex(end[0]);
@ -32,7 +38,7 @@ exports.buildRemoveRange = (rep, builder, start, end) => {
} }
}; };
exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { export const buildKeepRange = (rep: RepModel, builder: ChangeSetBuilder, start: [number, number], end:[number, number], attribs?: Attribute[], pool?: AttributePool) => {
const startLineOffset = rep.lines.offsetOfIndex(start[0]); const startLineOffset = rep.lines.offsetOfIndex(start[0]);
const endLineOffset = rep.lines.offsetOfIndex(end[0]); const endLineOffset = rep.lines.offsetOfIndex(end[0]);
@ -44,9 +50,25 @@ exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => {
} }
}; };
exports.buildKeepToStartOfRange = (rep, builder, start) => { export const buildKeepToStartOfRange = (rep: RepModel, builder: Builder, start: [number, number]) => {
const startLineOffset = rep.lines.offsetOfIndex(start[0]); const startLineOffset = rep.lines.offsetOfIndex(start[0]);
builder.keep(startLineOffset, start[0]); builder.keep(startLineOffset, start[0]);
builder.keep(start[1]); builder.keep(start[1]);
}; };
/**
* Parses a number from string base 36.
*
* @param {string} str - string of the number in base 36
* @returns {number} number
*/
export const parseNum = (str: string): number => parseInt(str, 36);
/**
* Writes a number in base 36 and puts it in a string.
*
* @param {number} num - number
* @returns {string} string
*/
export const numToString = (num: number): string => num.toString(36).toLowerCase();

View file

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const {padutils: {warnDeprecated}} = require('./pad_utils'); import padUtils from './pad_utils'
/** /**
* Represents a chat message stored in the database and transmitted among users. Plugins can extend * Represents a chat message stored in the database and transmitted among users. Plugins can extend
@ -8,14 +8,25 @@ const {padutils: {warnDeprecated}} = require('./pad_utils');
* *
* Supports serialization to JSON. * Supports serialization to JSON.
*/ */
class ChatMessage { export class ChatMessage {
static fromObject(obj) { customMetadata: any
text: string|null
public authorId: string|null
displayName: string|null
time: number|null
static fromObject(obj: ChatMessage) {
// The userId property was renamed to authorId, and userName was renamed to displayName. Accept // The userId property was renamed to authorId, and userName was renamed to displayName. Accept
// the old names in case the db record was written by an older version of Etherpad. // the old names in case the db record was written by an older version of Etherpad.
obj = Object.assign({}, obj); // Don't mutate the caller's object. obj = Object.assign({}, obj); // Don't mutate the caller's object.
if ('userId' in obj && !('authorId' in obj)) obj.authorId = obj.userId; if ('userId' in obj && !('authorId' in obj)) { // @ts-ignore
obj.authorId = obj.userId;
}
// @ts-ignore
delete obj.userId; delete obj.userId;
if ('userName' in obj && !('displayName' in obj)) obj.displayName = obj.userName; if ('userName' in obj && !('displayName' in obj)) { // @ts-ignore
obj.displayName = obj.userName;
}
// @ts-ignore
delete obj.userName; delete obj.userName;
return Object.assign(new ChatMessage(), obj); return Object.assign(new ChatMessage(), obj);
} }
@ -25,7 +36,7 @@ class ChatMessage {
* @param {?string} [authorId] - Initial value of the `authorId` property. * @param {?string} [authorId] - Initial value of the `authorId` property.
* @param {?number} [time] - Initial value of the `time` property. * @param {?number} [time] - Initial value of the `time` property.
*/ */
constructor(text = null, authorId = null, time = null) { constructor(text: string | null = null, authorId: string | null = null, time: number | null = null) {
/** /**
* The raw text of the user's chat message (before any rendering or processing). * The raw text of the user's chat message (before any rendering or processing).
* *
@ -62,11 +73,11 @@ class ChatMessage {
* @type {string} * @type {string}
*/ */
get userId() { get userId() {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
return this.authorId; return this.authorId;
} }
set userId(val) { set userId(val) {
warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); padUtils.warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead');
this.authorId = val; this.authorId = val;
} }
@ -77,11 +88,11 @@ class ChatMessage {
* @type {string} * @type {string}
*/ */
get userName() { get userName() {
warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
return this.displayName; return this.displayName;
} }
set userName(val) { set userName(val) {
warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); padUtils.warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead');
this.displayName = val; this.displayName = val;
} }
@ -89,10 +100,12 @@ class ChatMessage {
// doesn't support authorId and displayName. // doesn't support authorId and displayName.
toJSON() { toJSON() {
const {authorId, displayName, ...obj} = this; const {authorId, displayName, ...obj} = this;
// @ts-ignore
obj.userId = authorId; obj.userId = authorId;
// @ts-ignore
obj.userName = displayName; obj.userName = displayName;
return obj; return obj;
} }
} }
module.exports = ChatMessage; export default ChatMessage

View file

@ -0,0 +1,73 @@
import {OpAssembler} from "./OpAssembler";
import Op from "./Op";
import {clearOp, copyOp} from "./Changeset";
export class MergingOpAssembler {
private assem: OpAssembler;
private readonly bufOp: Op;
private bufOpAdditionalCharsAfterNewline: number;
constructor() {
this.assem = new OpAssembler()
this.bufOp = new Op()
// If we get, for example, insertions [xxx\n,yyy], those don't merge,
// but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n].
// This variable stores the length of yyy and any other newline-less
// ops immediately after it.
this.bufOpAdditionalCharsAfterNewline = 0;
}
/**
* @param {boolean} [isEndDocument]
*/
flush = (isEndDocument?: boolean) => {
if (!this.bufOp.opcode) return;
if (isEndDocument && this.bufOp.opcode === '=' && !this.bufOp.attribs) {
// final merged keep, leave it implicit
} else {
this.assem.append(this.bufOp);
if (this.bufOpAdditionalCharsAfterNewline) {
this.bufOp.chars = this.bufOpAdditionalCharsAfterNewline;
this.bufOp.lines = 0;
this.assem.append(this.bufOp);
this.bufOpAdditionalCharsAfterNewline = 0;
}
}
this.bufOp.opcode = '';
}
append = (op: Op) => {
if (op.chars <= 0) return;
if (this.bufOp.opcode === op.opcode && this.bufOp.attribs === op.attribs) {
if (op.lines > 0) {
// bufOp and additional chars are all mergeable into a multi-line op
this.bufOp.chars += this.bufOpAdditionalCharsAfterNewline + op.chars;
this.bufOp.lines += op.lines;
this.bufOpAdditionalCharsAfterNewline = 0;
} else if (this.bufOp.lines === 0) {
// both bufOp and op are in-line
this.bufOp.chars += op.chars;
} else {
// append in-line text to multi-line bufOp
this.bufOpAdditionalCharsAfterNewline += op.chars;
}
} else {
this.flush();
copyOp(op, this.bufOp);
}
}
endDocument = () => {
this.flush(true);
};
toString = () => {
this.flush();
return this.assem.toString();
};
clear = () => {
this.assem.clear();
clearOp(this.bufOp);
};
}

78
src/static/js/Op.ts Normal file
View file

@ -0,0 +1,78 @@
import {numToString} from "./ChangesetUtils";
export type OpCode = ''|'='|'+'|'-';
/**
* An operation to apply to a shared document.
*/
export default class Op {
opcode: ''|'='|'+'|'-'
chars: number
lines: number
attribs: string
/**
* @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property.
*/
constructor(opcode:''|'='|'+'|'-' = '') {
/**
* The operation's operator:
* - '=': Keep the next `chars` characters (containing `lines` newlines) from the base
* document.
* - '-': Remove the next `chars` characters (containing `lines` newlines) from the base
* document.
* - '+': Insert `chars` characters (containing `lines` newlines) at the current position in
* the document. The inserted characters come from the changeset's character bank.
* - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an
* operation.
*
* @type {(''|'='|'+'|'-')}
* @public
*/
this.opcode = opcode;
/**
* The number of characters to keep, insert, or delete.
*
* @type {number}
* @public
*/
this.chars = 0;
/**
* The number of characters among the `chars` characters that are newlines. If non-zero, the
* last character must be a newline.
*
* @type {number}
* @public
*/
this.lines = 0;
/**
* Identifiers of attributes to apply to the text, represented as a repeated (zero or more)
* sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example,
* '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The
* identifiers come from the document's attribute pool.
*
* For keep ('=') operations, the attributes are merged with the base text's existing
* attributes:
* - A keep op attribute with a non-empty value replaces an existing base text attribute that
* has the same key.
* - A keep op attribute with an empty value is interpreted as an instruction to remove an
* existing base text attribute that has the same key, if one exists.
*
* This is the empty string for remove ('-') operations.
*
* @type {string}
* @public
*/
this.attribs = '';
}
toString() {
if (!this.opcode) throw new TypeError('null op');
if (typeof this.attribs !== 'string') throw new TypeError('attribs must be a string');
const l = this.lines ? `|${numToString(this.lines)}` : '';
return this.attribs + l + this.opcode + numToString(this.chars);
}
}

View file

@ -0,0 +1,21 @@
import Op from "./Op";
import {assert} from './Changeset'
/**
* @returns {OpAssembler}
*/
export class OpAssembler {
private serialized: string;
constructor() {
this.serialized = ''
}
append = (op: Op) => {
assert(op instanceof Op, 'argument must be an instance of Op');
this.serialized += op.toString();
}
toString = () => this.serialized
clear = () => {
this.serialized = '';
}
}

47
src/static/js/OpIter.ts Normal file
View file

@ -0,0 +1,47 @@
import Op from "./Op";
import {clearOp, copyOp, deserializeOps} from "./Changeset";
/**
* Iterator over a changeset's operations.
*
* Note: This class does NOT implement the ECMAScript iterable or iterator protocols.
*
* @deprecated Use `deserializeOps` instead.
*/
export class OpIter {
private gen
private _next: IteratorResult<Op, void>
/**
* @param {string} ops - String encoding the change operations to iterate over.
*/
constructor(ops: string) {
this.gen = deserializeOps(ops);
this._next = this.gen.next();
}
/**
* @returns {boolean} Whether there are any remaining operations.
*/
hasNext(): boolean {
return !this._next.done;
}
/**
* Returns the next operation object and advances the iterator.
*
* Note: This does NOT implement the ECMAScript iterator protocol.
*
* @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value.
* @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are
* no more operations.
*/
next(opOut: Op = new Op()): Op {
if (this.hasNext()) {
copyOp(this._next.value!, opOut);
this._next = this.gen.next();
} else {
clearOp(opOut);
}
return opOut;
}
}

View file

@ -0,0 +1,115 @@
import {MergingOpAssembler} from "./MergingOpAssembler";
import {StringAssembler} from "./StringAssembler";
import padutils from "./pad_utils";
import Op from "./Op";
import { Attribute } from "./types/Attribute";
import AttributePool from "./AttributePool";
import {opsFromText} from "./Changeset";
/**
* Creates an object that allows you to append operations (type Op) and also compresses them if
* possible. Like MergingOpAssembler, but able to produce conforming exportss from slightly looser
* input, at the cost of speed. Specifically:
* - merges consecutive operations that can be merged
* - strips final "="
* - ignores 0-length changes
* - reorders consecutive + and - (which MergingOpAssembler doesn't do)
*
* @typedef {object} SmartOpAssembler
* @property {Function} append -
* @property {Function} appendOpWithText -
* @property {Function} clear -
* @property {Function} endDocument -
* @property {Function} getLengthChange -
* @property {Function} toString -
*/
export class SmartOpAssembler {
private minusAssem: MergingOpAssembler;
private plusAssem: MergingOpAssembler;
private keepAssem: MergingOpAssembler;
private lastOpcode: string;
private lengthChange: number;
private assem: StringAssembler;
constructor() {
this.minusAssem = new MergingOpAssembler()
this.plusAssem = new MergingOpAssembler()
this.keepAssem = new MergingOpAssembler()
this.assem = new StringAssembler()
this.lastOpcode = ''
this.lengthChange = 0
}
flushKeeps = () => {
this.assem.append(this.keepAssem.toString());
this.keepAssem.clear();
};
flushPlusMinus = () => {
this.assem.append(this.minusAssem.toString());
this.minusAssem.clear();
this.assem.append(this.plusAssem.toString());
this.plusAssem.clear();
};
append = (op: Op) => {
if (!op.opcode) return;
if (!op.chars) return;
if (op.opcode === '-') {
if (this.lastOpcode === '=') {
this.flushKeeps();
}
this.minusAssem.append(op);
this.lengthChange -= op.chars;
} else if (op.opcode === '+') {
if (this.lastOpcode === '=') {
this.flushKeeps();
}
this.plusAssem.append(op);
this.lengthChange += op.chars;
} else if (op.opcode === '=') {
if (this.lastOpcode !== '=') {
this.flushPlusMinus();
}
this.keepAssem.append(op);
}
this.lastOpcode = op.opcode;
};
/**
* Generates operations from the given text and attributes.
*
* @deprecated Use `opsFromText` instead.
* @param {('-'|'+'|'=')} opcode - The operator to use.
* @param {string} text - The text to remove/add/keep.
* @param {(string|Iterable<Attribute>)} attribs - The attributes to apply to the operations.
* @param {?AttributePool.ts} pool - Attribute pool. Only required if `attribs` is an iterable of
* attribute key, value pairs.
*/
appendOpWithText = (opcode: '-'|'+'|'=', text: string, attribs: Attribute[]|string, pool?: AttributePool) => {
padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' +
'use opsFromText() instead.');
for (const op of opsFromText(opcode, text, attribs, pool)) this.append(op);
};
toString = () => {
this.flushPlusMinus();
this.flushKeeps();
return this.assem.toString();
};
clear = () => {
this.minusAssem.clear();
this.plusAssem.clear();
this.keepAssem.clear();
this.assem.clear();
this.lengthChange = 0;
};
endDocument = () => {
this.keepAssem.endDocument();
};
getLengthChange = () => this.lengthChange;
}

View file

@ -0,0 +1,18 @@
/**
* @returns {StringAssembler}
*/
export class StringAssembler {
private str = ''
clear = ()=> {
this.str = '';
}
/**
* @param {string} x -
*/
append(x: string) {
this.str += String(x);
}
toString() {
return this.str
}
}

View file

@ -0,0 +1,54 @@
import {assert} from "./Changeset";
/**
* A custom made String Iterator
*
* @typedef {object} StringIterator
* @property {Function} newlines -
* @property {Function} peek -
* @property {Function} remaining -
* @property {Function} skip -
* @property {Function} take -
*/
/**
* @param {string} str - String to iterate over
* @returns {StringIterator}
*/
export class StringIterator {
private curIndex: number;
private newLines: number;
private str: String
constructor(str: string) {
this.curIndex = 0;
this.str = str
this.newLines = str.split('\n').length - 1;
}
remaining = () => this.str.length - this.curIndex;
getnewLines = () => this.newLines;
assertRemaining = (n: number) => {
assert(n <= this.remaining(), `!(${n} <= ${this.remaining()})`);
}
take = (n: number) => {
this.assertRemaining(n);
const s = this.str.substring(this.curIndex, this.curIndex+n);
this.newLines -= s.split('\n').length - 1;
this.curIndex += n;
return s;
}
peek = (n: number) => {
this.assertRemaining(n);
return this.str.substring(this.curIndex, this.curIndex+n);
}
skip = (n: number) => {
this.assertRemaining(n);
this.curIndex += n;
}
}

View file

@ -0,0 +1,348 @@
import {splitTextLines} from "./Changeset";
/**
* Class to iterate and modify texts which have several lines. It is used for applying Changesets on
* arrays of lines.
*
* Mutation operations have the same constraints as exports operations with respect to newlines, but
* not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability,
* final newline). Can be used to mutate lists of strings where the last char of each string is not
* actually a newline, but for the purposes of N and L values, the caller should pretend it is, and
* for things to work right in that case, the input to the `insert` method should be a single line
* with no newlines.
*/
class TextLinesMutator {
private _lines: string[];
private _curSplice: [number, number?];
private _inSplice: boolean;
private _curLine: number;
private _curCol: number;
/**
* @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place).
*/
constructor(lines: string[]) {
this._lines = lines;
/**
* this._curSplice holds values that will be passed as arguments to this._lines.splice() to
* insert, delete, or change lines:
* - this._curSplice[0] is an index into the this._lines array.
* - this._curSplice[1] is the number of lines that will be removed from the this._lines array
* starting at the index.
* - The other elements represent mutated (changed by ops) lines or new lines (added by ops)
* to insert at the index.
*
* @type {[number, number?, ...string[]?]}
*/
this._curSplice = [0, 0];
this._inSplice = false;
// position in lines after curSplice is applied:
this._curLine = 0;
this._curCol = 0;
// invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) &&
// curLine >= curSplice[0]
// invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then
// curCol == 0
}
/**
* Get a line from `lines` at given index.
*
* @param {number} idx - an index
* @returns {string}
*/
_linesGet(idx: number) {
if ('get' in this._lines) {
// @ts-ignore
return this._lines.get(idx) as string;
} else {
return this._lines[idx];
}
}
/**
* Return a slice from `lines`.
*
* @param {number} start - the start index
* @param {number} end - the end index
* @returns {string[]}
*/
_linesSlice(start: number | undefined, end: number | undefined) {
// can be unimplemented if removeLines's return value not needed
if (this._lines.slice) {
return this._lines.slice(start, end);
} else {
return [];
}
}
/**
* Return the length of `lines`.
*
* @returns {number}
*/
_linesLength() {
if (typeof this._lines.length === 'number') {
return this._lines.length;
} else {
// @ts-ignore
return this._lines.length();
}
}
/**
* Starts a new splice.
*/
_enterSplice() {
this._curSplice[0] = this._curLine;
this._curSplice[1] = 0;
// TODO(doc) when is this the case?
// check all enterSplice calls and changes to curCol
if (this._curCol > 0) this._putCurLineInSplice();
this._inSplice = true;
}
/**
* Changes the lines array according to the values in curSplice and resets curSplice. Called via
* close or TODO(doc).
*/
_leaveSplice() {
this._lines.splice(...this._curSplice);
this._curSplice.length = 2;
this._curSplice[0] = this._curSplice[1] = 0;
this._inSplice = false;
}
/**
* Indicates if curLine is already in the splice. This is necessary because the last element in
* curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting).
*
* @returns {boolean} true if curLine is in splice
*/
_isCurLineInSplice() {
// The value of `this._curSplice[1]` does not matter when determining the return value because
// `this._curLine` refers to the line number *after* the splice is applied (so after those lines
// are deleted).
return this._curLine - this._curSplice[0] < this._curSplice.length - 2;
}
/**
* Incorporates current line into the splice and marks its old position to be deleted.
*
* @returns {number} the index of the added line in curSplice
*/
_putCurLineInSplice() {
if (!this._isCurLineInSplice()) {
// @ts-ignore
this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1]));
// @ts-ignore
this._curSplice[1]++;
}
// TODO should be the same as this._curSplice.length - 1
return 2 + this._curLine - this._curSplice[0];
}
/**
* It will skip some newlines by putting them into the splice.
*
* @param {number} L -
* @param {boolean} includeInSplice - Indicates that attributes are present.
*/
skipLines(L: number, includeInSplice?: any) {
if (!L) return;
if (includeInSplice) {
if (!this._inSplice) this._enterSplice();
// TODO(doc) should this count the number of characters that are skipped to check?
for (let i = 0; i < L; i++) {
this._curCol = 0;
this._putCurLineInSplice();
this._curLine++;
}
} else {
if (this._inSplice) {
if (L > 1) {
// TODO(doc) figure out why single lines are incorporated into splice instead of ignored
this._leaveSplice();
} else {
this._putCurLineInSplice();
}
}
this._curLine += L;
this._curCol = 0;
}
// tests case foo in remove(), which isn't otherwise covered in current impl
}
/**
* Skip some characters. Can contain newlines.
*
* @param {number} N - number of characters to skip
* @param {number} L - number of newlines to skip
* @param {boolean} includeInSplice - indicates if attributes are present
*/
skip(N: number, L: number, includeInSplice?: any) {
if (!N) return;
if (L) {
this.skipLines(L, includeInSplice);
} else {
if (includeInSplice && !this._inSplice) this._enterSplice();
if (this._inSplice) {
// although the line is put into splice curLine is not increased, because
// only some chars are skipped, not the whole line
this._putCurLineInSplice();
}
this._curCol += N;
}
}
/**
* Remove whole lines from lines array.
*
* @param {number} L - number of lines to remove
* @returns {string}
*/
removeLines(L: number) {
if (!L) return '';
if (!this._inSplice) this._enterSplice();
/**
* Gets a string of joined lines after the end of the splice.
*
* @param {number} k - number of lines
* @returns {string} joined lines
*/
const nextKLinesText = (k: number) => {
// @ts-ignore
const m = this._curSplice[0] + this._curSplice[1];
return this._linesSlice(m, m + k).join('');
};
let removed = '';
if (this._isCurLineInSplice()) {
if (this._curCol === 0) {
// @ts-ignore
removed = this._curSplice[this._curSplice.length - 1];
this._curSplice.length--;
removed += nextKLinesText(L - 1);
// @ts-ignore
this._curSplice[1] += L - 1;
} else {
removed = nextKLinesText(L - 1);
// @ts-ignore
this._curSplice[1] += L - 1;
const sline = this._curSplice.length - 1;
// @ts-ignore
removed = this._curSplice[sline].substring(this._curCol) + removed;
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
// @ts-ignore
this._linesGet(this._curSplice[0] + this._curSplice[1]);
// @ts-ignore
this._curSplice[1] += 1;
}
} else {
removed = nextKLinesText(L);
this._curSplice[1]! += L;
}
return removed;
}
/**
* Remove text from lines array.
*
* @param {number} N - characters to delete
* @param {number} L - lines to delete
* @returns {string}
*/
remove(N: number, L: any) {
if (!N) return '';
if (L) return this.removeLines(L);
if (!this._inSplice) this._enterSplice();
// although the line is put into splice, curLine is not increased, because
// only some chars are removed not the whole line
const sline = this._putCurLineInSplice();
// @ts-ignore
const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N);
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
// @ts-ignore
this._curSplice[sline].substring(this._curCol + N);
return removed;
}
/**
* Inserts text into lines array.
*
* @param {string} text - the text to insert
* @param {number} L - number of newlines in text
*/
insert(text: string | any[], L: any) {
if (!text) return;
if (!this._inSplice) this._enterSplice();
if (L) {
// @ts-ignore
const newLines = splitTextLines(text);
if (this._isCurLineInSplice()) {
const sline = this._curSplice.length - 1;
/** @type {string} */
const theLine = this._curSplice[sline];
const lineCol = this._curCol;
// Insert the chars up to `curCol` and the first new line.
// @ts-ignore
this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
this._curLine++;
newLines!.splice(0, 1);
// insert the remaining new lines
// @ts-ignore
this._curSplice.push(...newLines);
this._curLine += newLines!.length;
// insert the remaining chars from the "old" line (e.g. the line we were in
// when we started to insert new lines)
// @ts-ignore
this._curSplice.push(theLine.substring(lineCol));
this._curCol = 0; // TODO(doc) why is this not set to the length of last line?
} else {
this._curSplice.push(...newLines);
this._curLine += newLines!.length;
}
} else {
// There are no additional lines. Although the line is put into splice, curLine is not
// increased because there may be more chars in the line (newline is not reached).
const sline = this._putCurLineInSplice();
if (!this._curSplice[sline]) {
const err = new Error(
'curSplice[sline] not populated, actual curSplice contents is ' +
`${JSON.stringify(this._curSplice)}. Possibly related to ` +
'https://github.com/ether/etherpad-lite/issues/2802');
console.error(err.stack || err.toString());
}
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text +
// @ts-ignore
this._curSplice[sline].substring(this._curCol);
this._curCol += text.length;
}
}
/**
* Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`.
*
* @returns {boolean} indicates if there are lines left
*/
hasMore() {
let docLines = this._linesLength();
if (this._inSplice) {
// @ts-ignore
docLines += this._curSplice.length - 2 - this._curSplice[1];
}
return this._curLine < docLines;
}
/**
* Closes the splice
*/
close() {
if (this._inSplice) this._leaveSplice();
}
}
export default TextLinesMutator

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.

View file

@ -6,6 +6,8 @@
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
import {MapArrayType} from "../../node/types/MapType";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -22,11 +24,13 @@
* limitations under the License. * limitations under the License.
*/ */
const isNodeText = (node) => (node.nodeType === 3); export const isNodeText = (node: {
nodeType: number
}) => (node.nodeType === 3);
const getAssoc = (obj, name) => obj[`_magicdom_${name}`]; export const getAssoc = (obj: MapArrayType<any>, name: string) => obj[`_magicdom_${name}`];
const setAssoc = (obj, name, value) => { export const setAssoc = (obj: MapArrayType<any>, name: string, value: string) => {
// note that in IE designMode, properties of a node can get // note that in IE designMode, properties of a node can get
// copied to new nodes that are spawned during editing; also, // copied to new nodes that are spawned during editing; also,
// properties representable in HTML text can survive copy-and-paste // properties representable in HTML text can survive copy-and-paste
@ -38,7 +42,7 @@ const setAssoc = (obj, name, value) => {
// between false and true, a number between 0 and numItems inclusive. // between false and true, a number between 0 and numItems inclusive.
const binarySearch = (numItems, func) => { export const binarySearch = (numItems: number, func: (num: number)=>boolean) => {
if (numItems < 1) return 0; if (numItems < 1) return 0;
if (func(0)) return 0; if (func(0)) return 0;
if (!func(numItems - 1)) return numItems; if (!func(numItems - 1)) return numItems;
@ -52,17 +56,10 @@ const binarySearch = (numItems, func) => {
return high; return high;
}; };
const binarySearchInfinite = (expectedLength, func) => { export const binarySearchInfinite = (expectedLength: number, func: (num: number)=>boolean) => {
let i = 0; let i = 0;
while (!func(i)) i += expectedLength; while (!func(i)) i += expectedLength;
return binarySearch(i, func); return binarySearch(i, func);
}; };
const noop = () => {}; export const noop = () => {};
exports.isNodeText = isNodeText;
exports.getAssoc = getAssoc;
exports.setAssoc = setAssoc;
exports.binarySearch = binarySearch;
exports.binarySearchInfinite = binarySearchInfinite;
exports.noop = noop;

View file

@ -1,4 +1,5 @@
'use strict'; // @ts-nocheck
import {Builder} from "./Builder";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
@ -18,30 +19,32 @@
*/ */
let documentAttributeManager; let documentAttributeManager;
const AttributeMap = require('./AttributeMap'); import AttributeMap from './AttributeMap';
const browser = require('./vendors/browser'); const browser = require('./vendors/browser');
const padutils = require('./pad_utils').padutils; import padutils from './pad_utils'
const Ace2Common = require('./ace2_common'); const Ace2Common = require('./ace2_common');
const $ = require('./rjquery').$; const $ = require('./rjquery').$;
import {characterRangeFollow, checkRep, cloneAText, compose, deserializeOps, filterAttribNumbers, inverse, isIdentity, makeAText, makeAttribution, mapAttribNumbers, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, opsFromAText, pack, splitAttributionLines} from './Changeset'
const isNodeText = Ace2Common.isNodeText; const isNodeText = Ace2Common.isNodeText;
const getAssoc = Ace2Common.getAssoc; const getAssoc = Ace2Common.getAssoc;
const setAssoc = Ace2Common.setAssoc; const setAssoc = Ace2Common.setAssoc;
const noop = Ace2Common.noop; const noop = Ace2Common.noop;
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
import SkipList from "./skiplist";
import Scroll from './scroll' import Scroll from './scroll'
import AttribPool from './AttributePool'
import {SmartOpAssembler} from "./SmartOpAssembler";
import Op from "./Op";
import {buildKeepRange, buildKeepToStartOfRange, buildRemoveRange} from './ChangesetUtils'
function Ace2Inner(editorInfo, cssManagers) { function Ace2Inner(editorInfo, cssManagers) {
const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const makeChangesetTracker = require('./changesettracker').makeChangesetTracker;
const colorutils = require('./colorutils').colorutils; const colorutils = require('./colorutils').colorutils;
const makeContentCollector = require('./contentcollector').makeContentCollector; const makeContentCollector = require('./contentcollector').makeContentCollector;
const domline = require('./domline').domline; const domline = require('./domline').domline;
const AttribPool = require('./AttributePool');
const Changeset = require('./Changeset');
const ChangesetUtils = require('./ChangesetUtils');
const linestylefilter = require('./linestylefilter').linestylefilter; const linestylefilter = require('./linestylefilter').linestylefilter;
const SkipList = require('./skiplist');
const undoModule = require('./undomodule').undoModule; const undoModule = require('./undomodule').undoModule;
const AttributeManager = require('./AttributeManager'); const AttributeManager = require('./AttributeManager');
const DEBUG = false; const DEBUG = false;
@ -174,9 +177,9 @@ function Ace2Inner(editorInfo, cssManagers) {
// CCCCCCCCCCCCCCCCCCCC\n // CCCCCCCCCCCCCCCCCCCC\n
// CCCC\n // CCCC\n
// end[0]: <CCC end[1] CCC>-------\n // end[0]: <CCC end[1] CCC>-------\n
const builder = Changeset.builder(rep.lines.totalWidth()); const builder = new Builder(rep.lines.totalWidth());
ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); buildKeepToStartOfRange(rep, builder, start);
ChangesetUtils.buildRemoveRange(rep, builder, start, end); buildRemoveRange(rep, builder, start, end);
builder.insert(newText, [ builder.insert(newText, [
['author', thisAuthor], ['author', thisAuthor],
], rep.apool); ], rep.apool);
@ -495,10 +498,10 @@ function Ace2Inner(editorInfo, cssManagers) {
}; };
const importAText = (atext, apoolJsonObj, undoable) => { const importAText = (atext, apoolJsonObj, undoable) => {
atext = Changeset.cloneAText(atext); atext = cloneAText(atext);
if (apoolJsonObj) { if (apoolJsonObj) {
const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj);
atext.attribs = Changeset.moveOpsToNewPool(atext.attribs, wireApool, rep.apool); atext.attribs = moveOpsToNewPool(atext.attribs, wireApool, rep.apool);
} }
inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => {
setDocAText(atext); setDocAText(atext);
@ -527,18 +530,18 @@ function Ace2Inner(editorInfo, cssManagers) {
const numLines = rep.lines.length(); const numLines = rep.lines.length();
const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); const upToLastLine = rep.lines.offsetOfIndex(numLines - 1);
const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; const lastLineLength = rep.lines.atIndex(numLines - 1).text.length;
const assem = Changeset.smartOpAssembler(); const assem = new SmartOpAssembler();
const o = new Changeset.Op('-'); const o = new Op('-');
o.chars = upToLastLine; o.chars = upToLastLine;
o.lines = numLines - 1; o.lines = numLines - 1;
assem.append(o); assem.append(o);
o.chars = lastLineLength; o.chars = lastLineLength;
o.lines = 0; o.lines = 0;
assem.append(o); assem.append(o);
for (const op of Changeset.opsFromAText(atext)) assem.append(op); for (const op of opsFromAText(atext)) assem.append(op);
const newLen = oldLen + assem.getLengthChange(); const newLen = oldLen + assem.getLengthChange();
const changeset = Changeset.checkRep( const changeset = checkRep(
Changeset.pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1)));
performDocumentApplyChangeset(changeset); performDocumentApplyChangeset(changeset);
performSelectionChange( performSelectionChange(
@ -552,7 +555,7 @@ function Ace2Inner(editorInfo, cssManagers) {
}; };
const setDocText = (text) => { const setDocText = (text) => {
setDocAText(Changeset.makeAText(text)); setDocAText(makeAText(text));
}; };
const getDocText = () => { const getDocText = () => {
@ -1271,7 +1274,7 @@ function Ace2Inner(editorInfo, cssManagers) {
if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) {
theIndent += THE_TAB; theIndent += THE_TAB;
} }
const cs = Changeset.builder(rep.lines.totalWidth()).keep( const cs = new Builder(rep.lines.totalWidth()).keep(
rep.lines.offsetOfIndex(lineNum), lineNum).insert( rep.lines.offsetOfIndex(lineNum), lineNum).insert(
theIndent, [ theIndent, [
['author', thisAuthor], ['author', thisAuthor],
@ -1423,7 +1426,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1];
const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1];
const result = const result =
Changeset.characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection); characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection);
requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart]; requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart];
} }
@ -1435,7 +1438,7 @@ function Ace2Inner(editorInfo, cssManagers) {
length: () => rep.lines.length(), length: () => rep.lines.length(),
}; };
Changeset.mutateTextLines(changes, linesMutatee); mutateTextLines(changes, linesMutatee);
if (requiredSelectionSetting) { if (requiredSelectionSetting) {
performSelectionChange( performSelectionChange(
@ -1446,10 +1449,10 @@ function Ace2Inner(editorInfo, cssManagers) {
}; };
const doRepApplyChangeset = (changes, insertsAfterSelection) => { const doRepApplyChangeset = (changes, insertsAfterSelection) => {
Changeset.checkRep(changes); checkRep(changes);
if (Changeset.oldLen(changes) !== rep.alltext.length) { if (oldLen(changes) !== rep.alltext.length) {
const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`; const errMsg = `${oldLen(changes)}/${rep.alltext.length}`;
throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);
} }
@ -1458,10 +1461,10 @@ function Ace2Inner(editorInfo, cssManagers) {
if (!editEvent.changeset) { if (!editEvent.changeset) {
editEvent.changeset = changes; editEvent.changeset = changes;
} else { } else {
editEvent.changeset = Changeset.compose(editEvent.changeset, changes, rep.apool); editEvent.changeset = compose(editEvent.changeset, changes, rep.apool);
} }
} else { } else {
const inverseChangeset = Changeset.inverse(changes, { const inverseChangeset = inverse(changes, {
get: (i) => `${rep.lines.atIndex(i).text}\n`, get: (i) => `${rep.lines.atIndex(i).text}\n`,
length: () => rep.lines.length(), length: () => rep.lines.length(),
}, rep.alines, rep.apool); }, rep.alines, rep.apool);
@ -1469,11 +1472,11 @@ function Ace2Inner(editorInfo, cssManagers) {
if (!editEvent.backset) { if (!editEvent.backset) {
editEvent.backset = inverseChangeset; editEvent.backset = inverseChangeset;
} else { } else {
editEvent.backset = Changeset.compose(inverseChangeset, editEvent.backset, rep.apool); editEvent.backset = compose(inverseChangeset, editEvent.backset, rep.apool);
} }
} }
Changeset.mutateAttributionLines(changes, rep.alines, rep.apool); mutateAttributionLines(changes, rep.alines, rep.apool);
if (changesetTracker.isTracking()) { if (changesetTracker.isTracking()) {
changesetTracker.composeUserChangeset(changes); changesetTracker.composeUserChangeset(changes);
@ -1582,7 +1585,7 @@ function Ace2Inner(editorInfo, cssManagers) {
let hasAttrib = true; let hasAttrib = true;
let indexIntoLine = 0; let indexIntoLine = 0;
for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { for (const op of deserializeOps(rep.alines[lineNum])) {
const opStartInLine = indexIntoLine; const opStartInLine = indexIntoLine;
const opEndInLine = opStartInLine + op.chars; const opEndInLine = opStartInLine + op.chars;
if (!hasIt(op.attribs)) { if (!hasIt(op.attribs)) {
@ -1627,7 +1630,7 @@ function Ace2Inner(editorInfo, cssManagers) {
if (n === selEndLine) { if (n === selEndLine) {
selectionEndInLine = rep.selEnd[1]; selectionEndInLine = rep.selEnd[1];
} }
for (const op of Changeset.deserializeOps(rep.alines[n])) { for (const op of deserializeOps(rep.alines[n])) {
const opStartInLine = indexIntoLine; const opStartInLine = indexIntoLine;
const opEndInLine = opStartInLine + op.chars; const opEndInLine = opStartInLine + op.chars;
if (!hasIt(op.attribs)) { if (!hasIt(op.attribs)) {
@ -1745,7 +1748,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine);
const startBuilder = () => { const startBuilder = () => {
const builder = Changeset.builder(oldLen); const builder = new Builder(oldLen);
builder.keep(spliceStartLineStart, spliceStartLine); builder.keep(spliceStartLineStart, spliceStartLine);
builder.keep(spliceStart - spliceStartLineStart); builder.keep(spliceStart - spliceStartLineStart);
return builder; return builder;
@ -1755,7 +1758,7 @@ function Ace2Inner(editorInfo, cssManagers) {
let textIndex = 0; let textIndex = 0;
const newTextStart = commonStart; const newTextStart = commonStart;
const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0); const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0);
for (const op of Changeset.deserializeOps(attribs)) { for (const op of deserializeOps(attribs)) {
const nextIndex = textIndex + op.chars; const nextIndex = textIndex + op.chars;
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs);
@ -1773,7 +1776,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// changeset the applies the styles found in the DOM. // changeset the applies the styles found in the DOM.
// This allows us to incorporate, e.g., Safari's native "unbold". // This allows us to incorporate, e.g., Safari's native "unbold".
const incorpedAttribClearer = cachedStrFunc( const incorpedAttribClearer = cachedStrFunc(
(oldAtts) => Changeset.mapAttribNumbers(oldAtts, (n) => { (oldAtts) => mapAttribNumbers(oldAtts, (n) => {
const k = rep.apool.getAttribKey(n); const k = rep.apool.getAttribKey(n);
if (isStyleAttribute(k)) { if (isStyleAttribute(k)) {
return rep.apool.putAttrib([k, '']); return rep.apool.putAttrib([k, '']);
@ -1799,7 +1802,7 @@ function Ace2Inner(editorInfo, cssManagers) {
}); });
const styler = builder2.toString(); const styler = builder2.toString();
theChangeset = Changeset.compose(clearer, styler, rep.apool); theChangeset = compose(clearer, styler, rep.apool);
} else { } else {
const builder = startBuilder(); const builder = startBuilder();
@ -1869,7 +1872,7 @@ function Ace2Inner(editorInfo, cssManagers) {
const attribRuns = (attribs) => { const attribRuns = (attribs) => {
const lengs = []; const lengs = [];
const atts = []; const atts = [];
for (const op of Changeset.deserializeOps(attribs)) { for (const op of deserializeOps(attribs)) {
lengs.push(op.chars); lengs.push(op.chars);
atts.push(op.attribs); atts.push(op.attribs);
} }
@ -1898,8 +1901,8 @@ function Ace2Inner(editorInfo, cssManagers) {
const newLen = newText.length; const newLen = newText.length;
const minLen = Math.min(oldLen, newLen); const minLen = Math.min(oldLen, newLen);
const oldARuns = attribRuns(Changeset.filterAttribNumbers(oldAttribs, incorpedAttribFilter)); const oldARuns = attribRuns(filterAttribNumbers(oldAttribs, incorpedAttribFilter));
const newARuns = attribRuns(Changeset.filterAttribNumbers(newAttribs, incorpedAttribFilter)); const newARuns = attribRuns(filterAttribNumbers(newAttribs, incorpedAttribFilter));
let commonStart = 0; let commonStart = 0;
const oldStartIter = attribIterator(oldARuns, false); const oldStartIter = attribIterator(oldARuns, false);
@ -2297,7 +2300,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// 3-renumber every list item of the same level from the beginning, level 1 // 3-renumber every list item of the same level from the beginning, level 1
// IMPORTANT: never skip a level because there imbrication may be arbitrary // IMPORTANT: never skip a level because there imbrication may be arbitrary
const builder = Changeset.builder(rep.lines.totalWidth()); const builder = new Builder(rep.lines.totalWidth());
let loc = [0, 0]; let loc = [0, 0];
const applyNumberList = (line, level) => { const applyNumberList = (line, level) => {
// init // init
@ -2312,8 +2315,8 @@ function Ace2Inner(editorInfo, cssManagers) {
if (isNaN(curLevel) || listType[0] === 'indent') { if (isNaN(curLevel) || listType[0] === 'indent') {
return line; return line;
} else if (curLevel === level) { } else if (curLevel === level) {
ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 0])); buildKeepRange(rep, builder, loc, (loc = [line, 0]));
ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 1]), [ buildKeepRange(rep, builder, loc, (loc = [line, 1]), [
['start', position], ['start', position],
], rep.apool); ], rep.apool);
@ -2330,7 +2333,7 @@ function Ace2Inner(editorInfo, cssManagers) {
applyNumberList(lineNum, 1); applyNumberList(lineNum, 1);
const cs = builder.toString(); const cs = builder.toString();
if (!Changeset.isIdentity(cs)) { if (!isIdentity(cs)) {
performDocumentApplyChangeset(cs); performDocumentApplyChangeset(cs);
} }
@ -2618,7 +2621,7 @@ function Ace2Inner(editorInfo, cssManagers) {
// TODO: There appears to be a race condition or so. // TODO: There appears to be a race condition or so.
const authorIds = new Set(); const authorIds = new Set();
if (alineAttrs) { if (alineAttrs) {
for (const op of Changeset.deserializeOps(alineAttrs)) { for (const op of deserializeOps(alineAttrs)) {
const authorId = AttributeMap.fromString(op.attribs, apool).get('author'); const authorId = AttributeMap.fromString(op.attribs, apool).get('author');
if (authorId) authorIds.add(authorId); if (authorId) authorIds.add(authorId);
} }
@ -3513,8 +3516,8 @@ function Ace2Inner(editorInfo, cssManagers) {
const oneEntry = createDomLineEntry(''); const oneEntry = createDomLineEntry('');
doRepLineSplice(0, rep.lines.length(), [oneEntry]); doRepLineSplice(0, rep.lines.length(), [oneEntry]);
insertDomLines(null, [oneEntry.domInfo]); insertDomLines(null, [oneEntry.domInfo]);
rep.alines = Changeset.splitAttributionLines( rep.alines = splitAttributionLines(
Changeset.makeAttribution('\n'), '\n'); makeAttribution('\n'), '\n');
bindTheEventHandlers(); bindTheEventHandlers();
}); });

View file

@ -17,6 +17,9 @@
* @typedef {string} AttributeString * @typedef {string} AttributeString
*/ */
import AttributePool from "./AttributePool";
import {Attribute} from "./types/Attribute";
/** /**
* Converts an attribute string into a sequence of attribute identifier numbers. * Converts an attribute string into a sequence of attribute identifier numbers.
* *
@ -28,7 +31,7 @@
* appear in `str`. * appear in `str`.
* @returns {Generator<number>} * @returns {Generator<number>}
*/ */
exports.decodeAttribString = function* (str) { export const decodeAttribString = function* (str: string): Generator<number> {
const re = /\*([0-9a-z]+)|./gy; const re = /\*([0-9a-z]+)|./gy;
let match; let match;
while ((match = re.exec(str)) != null) { while ((match = re.exec(str)) != null) {
@ -38,7 +41,7 @@ exports.decodeAttribString = function* (str) {
} }
}; };
const checkAttribNum = (n) => { const checkAttribNum = (n: number|object) => {
if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`); if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`);
if (n < 0) throw new Error(`attribute number is negative: ${n}`); if (n < 0) throw new Error(`attribute number is negative: ${n}`);
if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`); if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`);
@ -50,7 +53,7 @@ const checkAttribNum = (n) => {
* @param {Iterable<number>} attribNums - Sequence of attribute numbers. * @param {Iterable<number>} attribNums - Sequence of attribute numbers.
* @returns {AttributeString} * @returns {AttributeString}
*/ */
exports.encodeAttribString = (attribNums) => { export const encodeAttribString = (attribNums: Iterable<number>): string => {
let str = ''; let str = '';
for (const n of attribNums) { for (const n of attribNums) {
checkAttribNum(n); checkAttribNum(n);
@ -67,7 +70,7 @@ exports.encodeAttribString = (attribNums) => {
* @yields {Attribute} The identified attributes, in the same order as `attribNums`. * @yields {Attribute} The identified attributes, in the same order as `attribNums`.
* @returns {Generator<Attribute>} * @returns {Generator<Attribute>}
*/ */
exports.attribsFromNums = function* (attribNums, pool) { export const attribsFromNums = function* (attribNums: Iterable<number>, pool: AttributePool): Generator<Attribute> {
for (const n of attribNums) { for (const n of attribNums) {
checkAttribNum(n); checkAttribNum(n);
const attrib = pool.getAttrib(n); const attrib = pool.getAttrib(n);
@ -87,7 +90,7 @@ exports.attribsFromNums = function* (attribNums, pool) {
* @yields {number} The attribute number of each attribute in `attribs`, in order. * @yields {number} The attribute number of each attribute in `attribs`, in order.
* @returns {Generator<number>} * @returns {Generator<number>}
*/ */
exports.attribsToNums = function* (attribs, pool) { export const attribsToNums = function* (attribs: Iterable<Attribute>, pool: AttributePool) {
for (const attrib of attribs) yield pool.putAttrib(attrib); for (const attrib of attribs) yield pool.putAttrib(attrib);
}; };
@ -102,8 +105,8 @@ exports.attribsToNums = function* (attribs, pool) {
* @yields {Attribute} The attributes identified in `str`, in order. * @yields {Attribute} The attributes identified in `str`, in order.
* @returns {Generator<Attribute>} * @returns {Generator<Attribute>}
*/ */
exports.attribsFromString = function* (str, pool) { export const attribsFromString = function* (str: string, pool: AttributePool): Generator<Attribute> {
yield* exports.attribsFromNums(exports.decodeAttribString(str), pool); yield* attribsFromNums(decodeAttribString(str), pool);
}; };
/** /**
@ -116,8 +119,8 @@ exports.attribsFromString = function* (str, pool) {
* @param {AttributePool} pool - Attribute pool. * @param {AttributePool} pool - Attribute pool.
* @returns {AttributeString} * @returns {AttributeString}
*/ */
exports.attribsToString = export const attribsToString =
(attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool)); (attribs: Iterable<Attribute>, pool: AttributePool): string => encodeAttribString(attribsToNums(attribs, pool));
/** /**
* Sorts the attributes in canonical order. The order of entries with the same attribute name is * Sorts the attributes in canonical order. The order of entries with the same attribute name is
@ -126,5 +129,14 @@ exports.attribsToString =
* @param {Attribute[]} attribs - Attributes to sort in place. * @param {Attribute[]} attribs - Attributes to sort in place.
* @returns {Attribute[]} `attribs` (for chaining). * @returns {Attribute[]} `attribs` (for chaining).
*/ */
exports.sort = export const sort = (attribs: Attribute[]): Attribute[] => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));
(attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));
export default {
decodeAttribString,
encodeAttribString,
attribsFromNums,
attribsToNums,
attribsFromString,
attribsToString,
sort,
}

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0 // @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */ /* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -24,8 +25,8 @@
const makeCSSManager = require('./cssmanager').makeCSSManager; const makeCSSManager = require('./cssmanager').makeCSSManager;
const domline = require('./domline').domline; const domline = require('./domline').domline;
const AttribPool = require('./AttributePool'); import AttribPool from './AttributePool';
const Changeset = require('./Changeset'); import {compose, deserializeOps, inverse, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, splitAttributionLines, splitTextLines, unpack} from './Changeset';
const attributes = require('./attributes'); const attributes = require('./attributes');
const linestylefilter = require('./linestylefilter').linestylefilter; const linestylefilter = require('./linestylefilter').linestylefilter;
const colorutils = require('./colorutils').colorutils; const colorutils = require('./colorutils').colorutils;
@ -53,11 +54,11 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
currentRevision: clientVars.collab_client_vars.rev, currentRevision: clientVars.collab_client_vars.rev,
currentTime: clientVars.collab_client_vars.time, currentTime: clientVars.collab_client_vars.time,
currentLines: currentLines:
Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text), splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),
currentDivs: null, currentDivs: null,
// to be filled in once the dom loads // to be filled in once the dom loads
apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool), apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),
alines: Changeset.splitAttributionLines( alines: splitAttributionLines(
clientVars.collab_client_vars.initialAttributedText.attribs, clientVars.collab_client_vars.initialAttributedText.attribs,
clientVars.collab_client_vars.initialAttributedText.text), clientVars.collab_client_vars.initialAttributedText.text),
@ -120,7 +121,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
getActiveAuthors() { getActiveAuthors() {
const authorIds = new Set(); const authorIds = new Set();
for (const aline of this.alines) { for (const aline of this.alines) {
for (const op of Changeset.deserializeOps(aline)) { for (const op of deserializeOps(aline)) {
for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) { for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {
if (k !== 'author') continue; if (k !== 'author') continue;
if (v) authorIds.add(v); if (v) authorIds.add(v);
@ -141,7 +142,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
const oldAlines = padContents.alines.slice(); const oldAlines = padContents.alines.slice();
try { try {
// must mutate attribution lines before text lines // must mutate attribution lines before text lines
Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool); mutateAttributionLines(changeset, padContents.alines, padContents.apool);
} catch (e) { } catch (e) {
debugLog(e); debugLog(e);
} }
@ -163,7 +164,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
// some chars are replaced (no attributes change and no length change) // some chars are replaced (no attributes change and no length change)
// test if there are keep ops at the start of the cs // test if there are keep ops at the start of the cs
if (lineChanged === undefined) { if (lineChanged === undefined) {
const [op] = Changeset.deserializeOps(Changeset.unpack(changeset).ops); const [op] = deserializeOps(unpack(changeset).ops);
lineChanged = op != null && op.opcode === '=' ? op.lines : 0; lineChanged = op != null && op.opcode === '=' ? op.lines : 0;
} }
@ -183,7 +184,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
goToLineNumber(lineChanged); goToLineNumber(lineChanged);
} }
Changeset.mutateTextLines(changeset, padContents); mutateTextLines(changeset, padContents);
padContents.currentRevision = revision; padContents.currentRevision = revision;
padContents.currentTime += timeDelta * 1000; padContents.currentTime += timeDelta * 1000;
@ -272,7 +273,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
let changeset = cs[0]; let changeset = cs[0];
let timeDelta = path.times[0]; let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) { for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool); changeset = compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i]; timeDelta += path.times[i];
} }
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta); if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
@ -290,7 +291,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
let changeset = cs[0]; let changeset = cs[0];
let timeDelta = path.times[0]; let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) { for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool); changeset = compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i]; timeDelta += path.times[i];
} }
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta); if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
@ -396,9 +397,9 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
if (aend > data.actualEndNum - 1) aend = data.actualEndNum - 1; if (aend > data.actualEndNum - 1) aend = data.actualEndNum - 1;
// debugLog("adding changeset:", astart, aend); // debugLog("adding changeset:", astart, aend);
const forwardcs = const forwardcs =
Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool); moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);
const backwardcs = const backwardcs =
Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool); moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);
window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]); window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);
} }
if (callback) callback(start - 1, start + data.forwardsChangesets.length * granularity - 1); if (callback) callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);
@ -408,13 +409,13 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
obj = obj.data; obj = obj.data;
if (obj.type === 'NEW_CHANGES') { if (obj.type === 'NEW_CHANGES') {
const changeset = Changeset.moveOpsToNewPool( const changeset = moveOpsToNewPool(
obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool); obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
let changesetBack = Changeset.inverse( let changesetBack = inverse(
obj.changeset, padContents.currentLines, padContents.alines, padContents.apool); obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
changesetBack = Changeset.moveOpsToNewPool( changesetBack = moveOpsToNewPool(
changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool); changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta); loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -22,17 +23,18 @@
* limitations under the License. * limitations under the License.
*/ */
const AttributeMap = require('./AttributeMap'); import AttributeMap from './AttributeMap';
const AttributePool = require('./AttributePool'); import AttributePool from './AttributePool';
const Changeset = require('./Changeset'); import {applyToAText, checkRep, cloneAText, compose, deserializeOps, follow, identity, isIdentity, makeAText, moveOpsToNewPool, newLen, pack, prepareForWire, unpack} from './Changeset';
import {MergingOpAssembler} from "./MergingOpAssembler";
const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => { const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
// latest official text from server // latest official text from server
let baseAText = Changeset.makeAText('\n'); let baseAText = makeAText('\n');
// changes applied to baseText that have been submitted // changes applied to baseText that have been submitted
let submittedChangeset = null; let submittedChangeset = null;
// changes applied to submittedChangeset since it was prepared // changes applied to submittedChangeset since it was prepared
let userChangeset = Changeset.identity(1); let userChangeset = identity(1);
// is the changesetTracker enabled // is the changesetTracker enabled
let tracking = false; let tracking = false;
// stack state flag so that when we change the rep we don't // stack state flag so that when we change the rep we don't
@ -66,18 +68,18 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
return self = { return self = {
isTracking: () => tracking, isTracking: () => tracking,
setBaseText: (text) => { setBaseText: (text) => {
self.setBaseAttributedText(Changeset.makeAText(text), null); self.setBaseAttributedText(makeAText(text), null);
}, },
setBaseAttributedText: (atext, apoolJsonObj) => { setBaseAttributedText: (atext, apoolJsonObj) => {
aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => { aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => {
tracking = true; tracking = true;
baseAText = Changeset.cloneAText(atext); baseAText = cloneAText(atext);
if (apoolJsonObj) { if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool); baseAText.attribs = moveOpsToNewPool(baseAText.attribs, wireApool, apool);
} }
submittedChangeset = null; submittedChangeset = null;
userChangeset = Changeset.identity(atext.text.length); userChangeset = identity(atext.text.length);
applyingNonUserChanges = true; applyingNonUserChanges = true;
try { try {
callbacks.setDocumentAttributedText(atext); callbacks.setDocumentAttributedText(atext);
@ -89,8 +91,8 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
composeUserChangeset: (c) => { composeUserChangeset: (c) => {
if (!tracking) return; if (!tracking) return;
if (applyingNonUserChanges) return; if (applyingNonUserChanges) return;
if (Changeset.isIdentity(c)) return; if (isIdentity(c)) return;
userChangeset = Changeset.compose(userChangeset, c, apool); userChangeset = compose(userChangeset, c, apool);
setChangeCallbackTimeout(); setChangeCallbackTimeout();
}, },
@ -100,23 +102,23 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => { aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => {
if (apoolJsonObj) { if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj); const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
c = Changeset.moveOpsToNewPool(c, wireApool, apool); c = moveOpsToNewPool(c, wireApool, apool);
} }
baseAText = Changeset.applyToAText(c, baseAText, apool); baseAText = applyToAText(c, baseAText, apool);
let c2 = c; let c2 = c;
if (submittedChangeset) { if (submittedChangeset) {
const oldSubmittedChangeset = submittedChangeset; const oldSubmittedChangeset = submittedChangeset;
submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool); submittedChangeset = follow(c, oldSubmittedChangeset, false, apool);
c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool); c2 = follow(oldSubmittedChangeset, c, true, apool);
} }
const preferInsertingAfterUserChanges = true; const preferInsertingAfterUserChanges = true;
const oldUserChangeset = userChangeset; const oldUserChangeset = userChangeset;
userChangeset = Changeset.follow( userChangeset = follow(
c2, oldUserChangeset, preferInsertingAfterUserChanges, apool); c2, oldUserChangeset, preferInsertingAfterUserChanges, apool);
const postChange = Changeset.follow( const postChange = follow(
oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool); oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool);
const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor); const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor);
@ -135,17 +137,17 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
if (submittedChangeset) { if (submittedChangeset) {
// submission must have been canceled, prepare new changeset // submission must have been canceled, prepare new changeset
// that includes old submittedChangeset // that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool); toSubmit = compose(submittedChangeset, userChangeset, apool);
} else { } else {
// Get my authorID // Get my authorID
const authorId = parent.parent.pad.myUserInfo.userId; const authorId = parent.parent.pad.myUserInfo.userId;
// Sanitize authorship: Replace all author attributes with this user's author ID in case the // Sanitize authorship: Replace all author attributes with this user's author ID in case the
// text was copied from another author. // text was copied from another author.
const cs = Changeset.unpack(userChangeset); const cs = unpack(userChangeset);
const assem = Changeset.mergingOpAssembler(); const assem = new MergingOpAssembler();
for (const op of Changeset.deserializeOps(cs.ops)) { for (const op of deserializeOps(cs.ops)) {
if (op.opcode === '+') { if (op.opcode === '+') {
const attribs = AttributeMap.fromString(op.attribs, apool); const attribs = AttributeMap.fromString(op.attribs, apool);
const oldAuthorId = attribs.get('author'); const oldAuthorId = attribs.get('author');
@ -157,23 +159,23 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
assem.append(op); assem.append(op);
} }
assem.endDocument(); assem.endDocument();
userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank); userChangeset = pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank);
Changeset.checkRep(userChangeset); checkRep(userChangeset);
if (Changeset.isIdentity(userChangeset)) toSubmit = null; if (isIdentity(userChangeset)) toSubmit = null;
else toSubmit = userChangeset; else toSubmit = userChangeset;
} }
let cs = null; let cs = null;
if (toSubmit) { if (toSubmit) {
submittedChangeset = toSubmit; submittedChangeset = toSubmit;
userChangeset = Changeset.identity(Changeset.newLen(toSubmit)); userChangeset = identity(newLen(toSubmit));
cs = toSubmit; cs = toSubmit;
} }
let wireApool = null; let wireApool = null;
if (cs) { if (cs) {
const forWire = Changeset.prepareForWire(cs, apool); const forWire = prepareForWire(cs, apool);
wireApool = forWire.pool.toJsonable(); wireApool = forWire.pool.toJsonable();
cs = forWire.translated; cs = forWire.translated;
} }
@ -190,13 +192,13 @@ const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
throw new Error('applySubmittedChangesToBase: no submitted changes to apply'); throw new Error('applySubmittedChangesToBase: no submitted changes to apply');
} }
// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false)); // bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false));
baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool); baseAText = applyToAText(submittedChangeset, baseAText, apool);
submittedChangeset = null; submittedChangeset = null;
}, },
setUserChangeNotificationCallback: (callback) => { setUserChangeNotificationCallback: (callback) => {
changeCallback = callback; changeCallback = callback;
}, },
hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))), hasUncommittedChanges: () => !!(submittedChangeset || (!isIdentity(userChangeset))),
}; };
}; };

5
src/static/js/chat.js → src/static/js/chat.ts Executable file → Normal file
View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
@ -15,8 +16,8 @@
* limitations under the License. * limitations under the License.
*/ */
const ChatMessage = require('./ChatMessage'); import ChatMessage from './ChatMessage';
const padutils = require('./pad_utils').padutils; import padutils from './pad_utils'
const padcookie = require('./pad_cookie').padcookie; const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon'); const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,5 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
@ -8,6 +10,8 @@
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.contentcollector // THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.contentcollector
// %APPJET%: import("etherpad.collab.ace.easysync2.Changeset"); // %APPJET%: import("etherpad.collab.ace.easysync2.Changeset");
// %APPJET%: import("etherpad.admin.plugins"); // %APPJET%: import("etherpad.admin.plugins");
import Op from "./Op";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -26,9 +30,10 @@
const _MAX_LIST_LEVEL = 16; const _MAX_LIST_LEVEL = 16;
const AttributeMap = require('./AttributeMap'); import AttributeMap from './AttributeMap';
const UNorm = require('unorm'); import UNorm from 'unorm';
const Changeset = require('./Changeset'); import {subattribution} from './Changeset';
import {SmartOpAssembler} from "./SmartOpAssembler";
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const sanitizeUnicode = (s) => UNorm.nfc(s); const sanitizeUnicode = (s) => UNorm.nfc(s);
@ -83,14 +88,14 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
const textArray = []; const textArray = [];
const attribsArray = []; const attribsArray = [];
let attribsBuilder = null; let attribsBuilder = null;
const op = new Changeset.Op('+'); const op = new Op('+');
const self = { const self = {
length: () => textArray.length, length: () => textArray.length,
atColumnZero: () => textArray[textArray.length - 1] === '', atColumnZero: () => textArray[textArray.length - 1] === '',
startNew: () => { startNew: () => {
textArray.push(''); textArray.push('');
self.flush(true); self.flush(true);
attribsBuilder = Changeset.smartOpAssembler(); attribsBuilder = new SmartOpAssembler();
}, },
textOfLine: (i) => textArray[i], textOfLine: (i) => textArray[i],
appendText: (txt, attrString = '') => { appendText: (txt, attrString = '') => {
@ -653,8 +658,8 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
const lengthToTake = lineLimit; const lengthToTake = lineLimit;
newStrings.push(oldString.substring(0, lengthToTake)); newStrings.push(oldString.substring(0, lengthToTake));
oldString = oldString.substring(lengthToTake); oldString = oldString.substring(lengthToTake);
newAttribStrings.push(Changeset.subattribution(oldAttribString, 0, lengthToTake)); newAttribStrings.push(subattribution(oldAttribString, 0, lengthToTake));
oldAttribString = Changeset.subattribution(oldAttribString, lengthToTake); oldAttribString = subattribution(oldAttribString, lengthToTake);
} }
if (oldString.length > 0) { if (oldString.length > 0) {
newStrings.push(oldString); newStrings.push(oldString);

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline // THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -30,12 +31,13 @@
// requires: plugins // requires: plugins
// requires: undefined // requires: undefined
const Changeset = require('./Changeset'); import {deserializeOps} from './Changeset';
const attributes = require('./attributes'); import attributes from './attributes';
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const linestylefilter = {}; const linestylefilter = {};
const AttributeManager = require('./AttributeManager'); const AttributeManager = require('./AttributeManager');
const padutils = require('./pad_utils').padutils; import padutils from './pad_utils'
import Op from "./Op";
linestylefilter.ATTRIB_CLASSES = { linestylefilter.ATTRIB_CLASSES = {
bold: 'tag:b', bold: 'tag:b',
@ -98,12 +100,12 @@ linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool
return classes.substring(1); return classes.substring(1);
}; };
const attrOps = Changeset.deserializeOps(aline); const attrOps = deserializeOps(aline);
let attrOpsNext = attrOps.next(); let attrOpsNext = attrOps.next();
let nextOp, nextOpClasses; let nextOp, nextOpClasses;
const goNextOp = () => { const goNextOp = () => {
nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value; nextOp = attrOpsNext.done ? new Op() : attrOpsNext.value;
if (!attrOpsNext.done) attrOpsNext = attrOps.next(); if (!attrOpsNext.done) attrOpsNext = attrOps.next();
nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
}; };

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -33,7 +34,8 @@ require('./vendors/gritter');
import html10n from './vendors/html10n' import html10n from './vendors/html10n'
const Cookies = require('./pad_utils').Cookies; import {Cookies} from "./pad_utils";
const chat = require('./chat').chat; const chat = require('./chat').chat;
const getCollabClient = require('./collab_client').getCollabClient; const getCollabClient = require('./collab_client').getCollabClient;
const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus;
@ -44,9 +46,9 @@ const padimpexp = require('./pad_impexp').padimpexp;
const padmodals = require('./pad_modals').padmodals; const padmodals = require('./pad_modals').padmodals;
const padsavedrevs = require('./pad_savedrevs'); const padsavedrevs = require('./pad_savedrevs');
const paduserlist = require('./pad_userlist').paduserlist; const paduserlist = require('./pad_userlist').paduserlist;
const padutils = require('./pad_utils').padutils; import padutils from './pad_utils'
const colorutils = require('./colorutils').colorutils; const colorutils = require('./colorutils').colorutils;
const randomString = require('./pad_utils').randomString; import {randomString} from "./pad_utils";
const socketio = require('./socketio'); const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
import html10n from './vendors/html10n'; import html10n from './vendors/html10n';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -16,7 +17,7 @@
* limitations under the License. * limitations under the License.
*/ */
const Cookies = require('./pad_utils').Cookies; import {Cookies} from "./pad_utils";
exports.padcookie = new class { exports.padcookie = new class {
constructor() { constructor() {

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -24,7 +25,7 @@
const browser = require('./vendors/browser'); const browser = require('./vendors/browser');
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils; import padutils from "./pad_utils";
const padeditor = require('./pad_editor').padeditor; const padeditor = require('./pad_editor').padeditor;
const padsavedrevs = require('./pad_savedrevs'); const padsavedrevs = require('./pad_savedrevs');
const _ = require('underscore'); const _ = require('underscore');

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
* This code is mostly from the old Etherpad. Please help us to comment this code. * This code is mostly from the old Etherpad. Please help us to comment this code.
@ -21,9 +22,8 @@
* limitations under the License. * limitations under the License.
*/ */
const Cookies = require('./pad_utils').Cookies; import padutils,{Cookies} from "./pad_utils";
const padcookie = require('./pad_cookie').padcookie; const padcookie = require('./pad_cookie').padcookie;
const padutils = require('./pad_utils').padutils;
const Ace2Editor = require('./ace').Ace2Editor; const Ace2Editor = require('./ace').Ace2Editor;
import html10n from '../js/vendors/html10n' import html10n from '../js/vendors/html10n'

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
/** /**
@ -16,7 +17,7 @@
* limitations under the License. * limitations under the License.
*/ */
const padutils = require('./pad_utils').padutils; import padutils from './pad_utils'
const hooks = require('./pluginfw/hooks'); const hooks = require('./pluginfw/hooks');
import html10n from './vendors/html10n'; import html10n from './vendors/html10n';
let myUserInfo = {}; let myUserInfo = {};

View file

@ -6,6 +6,8 @@
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/ */
import {binarySearch} from "./ace2_common";
/** /**
* Copyright 2009 Google Inc. * Copyright 2009 Google Inc.
* *
@ -22,13 +24,14 @@
* limitations under the License. * limitations under the License.
*/ */
const Security = require('./security'); const Security = require('security');
import jsCookie, {CookiesStatic} from 'js-cookie'
/** /**
* Generates a random String with the given length. Is needed to generate the Author, Group, * Generates a random String with the given length. Is needed to generate the Author, Group,
* readonly, session Ids * readonly, session Ids
*/ */
const randomString = (len) => { export const randomString = (len?: number) => {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let randomstring = ''; let randomstring = '';
len = len || 20; len = len || 20;
@ -85,13 +88,41 @@ const urlRegex = (() => {
'tel', 'tel',
].join('|')}):`; ].join('|')}):`;
return new RegExp( return new RegExp(
`(?:${withAuth}|${withoutAuth}|www\\.)${urlChar}*(?!${postUrlPunct})${urlChar}`, 'g'); `(?:${withAuth}|${withoutAuth}|www\\.)${urlChar}*(?!${postUrlPunct})${urlChar}`, 'g');
})(); })();
// https://stackoverflow.com/a/68957976 // https://stackoverflow.com/a/68957976
const base64url = /^(?=(?:.{4})*$)[A-Za-z0-9_-]*(?:[AQgw]==|[AEIMQUYcgkosw048]=)?$/; const base64url = /^(?=(?:.{4})*$)[A-Za-z0-9_-]*(?:[AQgw]==|[AEIMQUYcgkosw048]=)?$/;
const padutils = { type PadEvent = {
which: number
}
type JQueryNode = JQuery<HTMLElement>
class PadUtils {
public urlRegex: RegExp
public wordCharRegex: RegExp
public warnDeprecatedFlags: {
disabledForTestingOnly: boolean,
_rl?: {
prevs: Map<string, number>,
now: () => number,
period: number
}
logger?: any
}
public globalExceptionHandler: null | any = null;
constructor() {
this.warnDeprecatedFlags = {
disabledForTestingOnly: false
}
this.wordCharRegex = wordCharRegex
this.urlRegex = urlRegex
}
/** /**
* Prints a warning message followed by a stack trace (to make it easier to figure out what code * Prints a warning message followed by a stack trace (to make it easier to figure out what code
* is using the deprecated function). * is using the deprecated function).
@ -107,41 +138,41 @@ const padutils = {
* @param {...*} args - Passed to `padutils.warnDeprecated.logger.warn` (or `console.warn` if no * @param {...*} args - Passed to `padutils.warnDeprecated.logger.warn` (or `console.warn` if no
* logger is set), with a stack trace appended if available. * logger is set), with a stack trace appended if available.
*/ */
warnDeprecated: (...args) => { warnDeprecated = (...args: any[]) => {
if (padutils.warnDeprecated.disabledForTestingOnly) return; if (this.warnDeprecatedFlags.disabledForTestingOnly) return;
const err = new Error(); const err = new Error();
if (Error.captureStackTrace) Error.captureStackTrace(err, padutils.warnDeprecated); if (Error.captureStackTrace) Error.captureStackTrace(err, this.warnDeprecated);
err.name = ''; err.name = '';
// Rate limit identical deprecation warnings (as determined by the stack) to avoid log spam. // Rate limit identical deprecation warnings (as determined by the stack) to avoid log spam.
if (typeof err.stack === 'string') { if (typeof err.stack === 'string') {
if (padutils.warnDeprecated._rl == null) { if (this.warnDeprecatedFlags._rl == null) {
padutils.warnDeprecated._rl = this.warnDeprecatedFlags._rl =
{prevs: new Map(), now: () => Date.now(), period: 10 * 60 * 1000}; {prevs: new Map(), now: () => Date.now(), period: 10 * 60 * 1000};
} }
const rl = padutils.warnDeprecated._rl; const rl = this.warnDeprecatedFlags._rl;
const now = rl.now(); const now = rl.now();
const prev = rl.prevs.get(err.stack); const prev = rl.prevs.get(err.stack);
if (prev != null && now - prev < rl.period) return; if (prev != null && now - prev < rl.period) return;
rl.prevs.set(err.stack, now); rl.prevs.set(err.stack, now);
} }
if (err.stack) args.push(err.stack); if (err.stack) args.push(err.stack);
(padutils.warnDeprecated.logger || console).warn(...args); (this.warnDeprecatedFlags.logger || console).warn(...args);
}, }
escapeHtml = (x: string) => Security.escapeHTML(String(x))
escapeHtml: (x) => Security.escapeHTML(String(x)), uniqueId = () => {
uniqueId: () => {
const pad = require('./pad').pad; // Sidestep circular dependency const pad = require('./pad').pad; // Sidestep circular dependency
// returns string that is exactly 'width' chars, padding with zeros and taking rightmost digits // returns string that is exactly 'width' chars, padding with zeros and taking rightmost digits
const encodeNum = const encodeNum =
(n, width) => (Array(width + 1).join('0') + Number(n).toString(35)).slice(-width); (n: number, width: number) => (Array(width + 1).join('0') + Number(n).toString(35)).slice(-width);
return [ return [
pad.getClientIp(), pad.getClientIp(),
encodeNum(+new Date(), 7), encodeNum(+new Date(), 7),
encodeNum(Math.floor(Math.random() * 1e9), 4), encodeNum(Math.floor(Math.random() * 1e9), 4),
].join('.'); ].join('.');
}, }
// e.g. "Thu Jun 18 2009 13:09" // e.g. "Thu Jun 18 2009 13:09"
simpleDateTime: (date) => { simpleDateTime = (date: string) => {
const d = new Date(+date); // accept either number or date const d = new Date(+date); // accept either number or date
const dayOfWeek = (['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'])[d.getDay()]; const dayOfWeek = (['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'])[d.getDay()];
const month = ([ const month = ([
@ -162,16 +193,14 @@ const padutils = {
const year = d.getFullYear(); const year = d.getFullYear();
const hourmin = `${d.getHours()}:${(`0${d.getMinutes()}`).slice(-2)}`; const hourmin = `${d.getHours()}:${(`0${d.getMinutes()}`).slice(-2)}`;
return `${dayOfWeek} ${month} ${dayOfMonth} ${year} ${hourmin}`; return `${dayOfWeek} ${month} ${dayOfMonth} ${year} ${hourmin}`;
}, }
wordCharRegex,
urlRegex,
// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...] // returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...]
findURLs: (text) => { findURLs = (text: string) => {
// Copy padutils.urlRegex so that the use of .exec() below (which mutates the RegExp object) // Copy padutils.urlRegex so that the use of .exec() below (which mutates the RegExp object)
// does not break other concurrent uses of padutils.urlRegex. // does not break other concurrent uses of padutils.urlRegex.
const urlRegex = new RegExp(padutils.urlRegex, 'g'); const urlRegex = new RegExp(this.urlRegex, 'g');
urlRegex.lastIndex = 0; urlRegex.lastIndex = 0;
let urls = null; let urls: [number, string][] | null = null;
let execResult; let execResult;
// TODO: Switch to String.prototype.matchAll() after support for Node.js < 12.0.0 is dropped. // TODO: Switch to String.prototype.matchAll() after support for Node.js < 12.0.0 is dropped.
while ((execResult = urlRegex.exec(text))) { while ((execResult = urlRegex.exec(text))) {
@ -181,18 +210,19 @@ const padutils = {
urls.push([startIndex, url]); urls.push([startIndex, url]);
} }
return urls; return urls;
}, }
escapeHtmlWithClickableLinks: (text, target) => { escapeHtmlWithClickableLinks = (text: string, target: string) => {
let idx = 0; let idx = 0;
const pieces = []; const pieces = [];
const urls = padutils.findURLs(text); const urls = this.findURLs(text);
const advanceTo = (i) => { const advanceTo = (i: number) => {
if (i > idx) { if (i > idx) {
pieces.push(Security.escapeHTML(text.substring(idx, i))); pieces.push(Security.escapeHTML(text.substring(idx, i)));
idx = i; idx = i;
}
} }
}; ;
if (urls) { if (urls) {
for (let j = 0; j < urls.length; j++) { for (let j = 0; j < urls.length; j++) {
const startIndex = urls[j][0]; const startIndex = urls[j][0];
@ -206,25 +236,25 @@ const padutils = {
// https://mathiasbynens.github.io/rel-noopener/ // https://mathiasbynens.github.io/rel-noopener/
// https://github.com/ether/etherpad-lite/pull/3636 // https://github.com/ether/etherpad-lite/pull/3636
pieces.push( pieces.push(
'<a ', '<a ',
(target ? `target="${Security.escapeHTMLAttribute(target)}" ` : ''), (target ? `target="${Security.escapeHTMLAttribute(target)}" ` : ''),
'href="', 'href="',
Security.escapeHTMLAttribute(href), Security.escapeHTMLAttribute(href),
'" rel="noreferrer noopener">'); '" rel="noreferrer noopener">');
advanceTo(startIndex + href.length); advanceTo(startIndex + href.length);
pieces.push('</a>'); pieces.push('</a>');
} }
} }
advanceTo(text.length); advanceTo(text.length);
return pieces.join(''); return pieces.join('');
}, }
bindEnterAndEscape: (node, onEnter, onEscape) => { bindEnterAndEscape = (node: JQueryNode, onEnter: Function, onEscape: Function) => {
// Use keypress instead of keyup in bindEnterAndEscape. Keyup event is fired on enter in IME // Use keypress instead of keyup in bindEnterAndEscape. Keyup event is fired on enter in IME
// (Input Method Editor), But keypress is not. So, I changed to use keypress instead of keyup. // (Input Method Editor), But keypress is not. So, I changed to use keypress instead of keyup.
// It is work on Windows (IE8, Chrome 6.0.472), CentOs (Firefox 3.0) and Mac OSX (Firefox // It is work on Windows (IE8, Chrome 6.0.472), CentOs (Firefox 3.0) and Mac OSX (Firefox
// 3.6.10, Chrome 6.0.472, Safari 5.0). // 3.6.10, Chrome 6.0.472, Safari 5.0).
if (onEnter) { if (onEnter) {
node.on('keypress', (evt) => { node.on('keypress', (evt: { which: number; }) => {
if (evt.which === 13) { if (evt.which === 13) {
onEnter(evt); onEnter(evt);
} }
@ -238,13 +268,15 @@ const padutils = {
} }
}); });
} }
}, }
timediff: (d) => {
timediff = (d: number) => {
const pad = require('./pad').pad; // Sidestep circular dependency const pad = require('./pad').pad; // Sidestep circular dependency
const format = (n, word) => { const format = (n: number, word: string) => {
n = Math.round(n); n = Math.round(n);
return (`${n} ${word}${n !== 1 ? 's' : ''} ago`); return (`${n} ${word}${n !== 1 ? 's' : ''} ago`);
}; }
;
d = Math.max(0, (+(new Date()) - (+d) - pad.clientTimeOffset) / 1000); d = Math.max(0, (+(new Date()) - (+d) - pad.clientTimeOffset) / 1000);
if (d < 60) { if (d < 60) {
return format(d, 'second'); return format(d, 'second');
@ -259,78 +291,89 @@ const padutils = {
} }
d /= 24; d /= 24;
return format(d, 'day'); return format(d, 'day');
}, }
makeAnimationScheduler: (funcToAnimateOneStep, stepTime, stepsAtOnce) => { makeAnimationScheduler =
if (stepsAtOnce === undefined) { (funcToAnimateOneStep: any, stepTime: number, stepsAtOnce?: number) => {
stepsAtOnce = 1; if (stepsAtOnce === undefined) {
stepsAtOnce = 1;
}
let animationTimer: any = null;
const scheduleAnimation = () => {
if (!animationTimer) {
animationTimer = window.setTimeout(() => {
animationTimer = null;
let n = stepsAtOnce;
let moreToDo = true;
while (moreToDo && n > 0) {
moreToDo = funcToAnimateOneStep();
n--;
}
if (moreToDo) {
// more to do
scheduleAnimation();
}
}, stepTime * stepsAtOnce);
}
};
return {scheduleAnimation};
} }
let animationTimer = null; makeFieldLabeledWhenEmpty
=
(field: JQueryNode, labelText: string) => {
field = $(field);
const scheduleAnimation = () => { const clear = () => {
if (!animationTimer) { field.addClass('editempty');
animationTimer = window.setTimeout(() => { field.val(labelText);
animationTimer = null; }
let n = stepsAtOnce; ;
let moreToDo = true; field.focus(() => {
while (moreToDo && n > 0) { if (field.hasClass('editempty')) {
moreToDo = funcToAnimateOneStep(); field.val('');
n--; }
} field.removeClass('editempty');
if (moreToDo) { });
// more to do field.on('blur', () => {
scheduleAnimation(); if (!field.val()) {
} clear();
}, stepTime * stepsAtOnce); }
} });
}; return {
return {scheduleAnimation}; clear,
}, };
makeFieldLabeledWhenEmpty: (field, labelText) => {
field = $(field);
const clear = () => {
field.addClass('editempty');
field.val(labelText);
};
field.focus(() => {
if (field.hasClass('editempty')) {
field.val('');
}
field.removeClass('editempty');
});
field.on('blur', () => {
if (!field.val()) {
clear();
}
});
return {
clear,
};
},
getCheckbox: (node) => $(node).is(':checked'),
setCheckbox: (node, value) => {
if (value) {
$(node).attr('checked', 'checked');
} else {
$(node).prop('checked', false);
} }
}, getCheckbox = (node: string) => $(node).is(':checked')
bindCheckboxChange: (node, func) => { setCheckbox =
$(node).on('change', func); (node: JQueryNode, value: boolean) => {
}, if (value) {
encodeUserId: (userId) => userId.replace(/[^a-y0-9]/g, (c) => { $(node).attr('checked', 'checked');
if (c === '.') return '-'; } else {
return `z${c.charCodeAt(0)}z`; $(node).prop('checked', false);
}), }
decodeUserId: (encodedUserId) => encodedUserId.replace(/[a-y0-9]+|-|z.+?z/g, (cc) => {
if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') {
return String.fromCharCode(Number(cc.slice(1, -1)));
} else {
return cc;
} }
}), bindCheckboxChange =
(node: JQueryNode, func: Function) => {
// @ts-ignore
$(node).on("change", func);
}
encodeUserId =
(userId: string) => userId.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-';
return `z${c.charCodeAt(0)}z`;
})
decodeUserId =
(encodedUserId: string) => encodedUserId.replace(/[a-y0-9]+|-|z.+?z/g, (cc) => {
if (cc === '-') {
return '.';
} else if (cc.charAt(0) === 'z') {
return String.fromCharCode(Number(cc.slice(1, -1)));
} else {
return cc;
}
})
/** /**
* Returns whether a string has the expected format to be used as a secret token identifying an * Returns whether a string has the expected format to be used as a secret token identifying an
* author. The format is defined as: 't.' followed by a non-empty base64url string (RFC 4648 * author. The format is defined as: 't.' followed by a non-empty base64url string (RFC 4648
@ -340,109 +383,109 @@ const padutils = {
* conditional transformation of a token to a database key in a way that does not allow a * conditional transformation of a token to a database key in a way that does not allow a
* malicious user to impersonate another user). * malicious user to impersonate another user).
*/ */
isValidAuthorToken: (t) => { isValidAuthorToken = (t: string | object) => {
if (typeof t !== 'string' || !t.startsWith('t.')) return false; if (typeof t !== 'string' || !t.startsWith('t.')) return false;
const v = t.slice(2); const v = t.slice(2);
return v.length > 0 && base64url.test(v); return v.length > 0 && base64url.test(v);
}, }
/** /**
* Returns a string that can be used in the `token` cookie as a secret that authenticates a * Returns a string that can be used in the `token` cookie as a secret that authenticates a
* particular author. * particular author.
*/ */
generateAuthorToken: () => `t.${randomString()}`, generateAuthorToken = () => `t.${randomString()}`
}; setupGlobalExceptionHandler = () => {
if (this.globalExceptionHandler == null) {
let globalExceptionHandler = null; this.globalExceptionHandler = (e: any) => {
padutils.setupGlobalExceptionHandler = () => { let type;
if (globalExceptionHandler == null) { let err;
globalExceptionHandler = (e) => { let msg, url, linenumber;
let type; if (e instanceof ErrorEvent) {
let err; type = 'Uncaught exception';
let msg, url, linenumber; err = e.error || {};
if (e instanceof ErrorEvent) { ({message: msg, filename: url, lineno: linenumber} = e);
type = 'Uncaught exception'; } else if (e instanceof PromiseRejectionEvent) {
err = e.error || {}; type = 'Unhandled Promise rejection';
({message: msg, filename: url, lineno: linenumber} = e); err = e.reason || {};
} else if (e instanceof PromiseRejectionEvent) { ({message: msg = 'unknown', fileName: url = 'unknown', lineNumber: linenumber = -1} = err);
type = 'Unhandled Promise rejection'; } else {
err = e.reason || {}; throw new Error(`unknown event: ${e.toString()}`);
({message: msg = 'unknown', fileName: url = 'unknown', lineNumber: linenumber = -1} = err);
} else {
throw new Error(`unknown event: ${e.toString()}`);
}
if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {
msg = `${err.name}: ${msg}`;
}
const errorId = randomString(20);
let msgAlreadyVisible = false;
$('.gritter-item .error-msg').each(function () {
if ($(this).text() === msg) {
msgAlreadyVisible = true;
} }
}); if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {
msg = `${err.name}: ${msg}`;
}
const errorId = randomString(20);
if (!msgAlreadyVisible) { let msgAlreadyVisible = false;
const txt = document.createTextNode.bind(document); // Convenience shorthand. $('.gritter-item .error-msg').each(function () {
const errorMsg = [ if ($(this).text() === msg) {
$('<p>') msgAlreadyVisible = true;
}
});
if (!msgAlreadyVisible) {
const txt = document.createTextNode.bind(document); // Convenience shorthand.
const errorMsg = [
$('<p>')
.append($('<b>').text('Please press and hold Ctrl and press F5 to reload this page')), .append($('<b>').text('Please press and hold Ctrl and press F5 to reload this page')),
$('<p>') $('<p>')
.text('If the problem persists, please send this error message to your webmaster:'), .text('If the problem persists, please send this error message to your webmaster:'),
$('<div>').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em') $('<div>').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em')
.append($('<b>').addClass('error-msg').text(msg)).append($('<br>')) .append($('<b>').addClass('error-msg').text(msg)).append($('<br>'))
.append(txt(`at ${url} at line ${linenumber}`)).append($('<br>')) .append(txt(`at ${url} at line ${linenumber}`)).append($('<br>'))
.append(txt(`ErrorId: ${errorId}`)).append($('<br>')) .append(txt(`ErrorId: ${errorId}`)).append($('<br>'))
.append(txt(type)).append($('<br>')) .append(txt(type)).append($('<br>'))
.append(txt(`URL: ${window.location.href}`)).append($('<br>')) .append(txt(`URL: ${window.location.href}`)).append($('<br>'))
.append(txt(`UserAgent: ${navigator.userAgent}`)).append($('<br>')), .append(txt(`UserAgent: ${navigator.userAgent}`)).append($('<br>')),
]; ];
$.gritter.add({ // @ts-ignore
title: 'An error occurred', $.gritter.add({
text: errorMsg, title: 'An error occurred',
class_name: 'error', text: errorMsg,
position: 'bottom', class_name: 'error',
sticky: true, position: 'bottom',
sticky: true,
});
}
// send javascript errors to the server
$.post('../jserror', {
errorInfo: JSON.stringify({
errorId,
type,
msg,
url: window.location.href,
source: url,
linenumber,
userAgent: navigator.userAgent,
stack: err.stack,
}),
}); });
} };
window.onerror = null; // Clear any pre-existing global error handler.
// send javascript errors to the server window.addEventListener('error', this.globalExceptionHandler);
$.post('../jserror', { window.addEventListener('unhandledrejection', this.globalExceptionHandler);
errorInfo: JSON.stringify({ }
errorId,
type,
msg,
url: window.location.href,
source: url,
linenumber,
userAgent: navigator.userAgent,
stack: err.stack,
}),
});
};
window.onerror = null; // Clear any pre-existing global error handler.
window.addEventListener('error', globalExceptionHandler);
window.addEventListener('unhandledrejection', globalExceptionHandler);
} }
}; binarySearch = binarySearch
}
padutils.binarySearch = require('./ace2_common').binarySearch;
// https://stackoverflow.com/a/42660748 // https://stackoverflow.com/a/42660748
const inThirdPartyIframe = () => { const inThirdPartyIframe = () => {
try { try {
return (!window.top.location.hostname); return (!window.top!.location.hostname);
} catch (e) { } catch (e) {
return true; return true;
} }
}; };
export let Cookies: CookiesStatic<string>
// This file is included from Node so that it can reuse randomString, but Node doesn't have a global // This file is included from Node so that it can reuse randomString, but Node doesn't have a global
// window object. // window object.
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
exports.Cookies = require('js-cookie').withAttributes({ Cookies = jsCookie.withAttributes({
// Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case // Use `SameSite=Lax`, unless Etherpad is embedded in an iframe from another site in which case
// use `SameSite=None`. For iframes from another site, only `None` has a chance of working // use `SameSite=None`. For iframes from another site, only `None` has a chance of working
// because the cookies are third-party (not same-site). Many browsers/users block third-party // because the cookies are third-party (not same-site). Many browsers/users block third-party
@ -455,5 +498,5 @@ if (typeof window !== 'undefined') {
secure: window.location.protocol === 'https:', secure: window.location.protocol === 'https:',
}); });
} }
exports.randomString = randomString;
exports.padutils = padutils; export default new PadUtils()

View file

@ -44,6 +44,12 @@ export class LinkInstaller {
await this.checkLinkedDependencies(installedPlugin) await this.checkLinkedDependencies(installedPlugin)
} }
public async installFromGitHub(repository: string) {
const installedPlugin = await this.livePluginManager.installFromGithub(repository)
this.linkDependency(installedPlugin.name)
await this.checkLinkedDependencies(installedPlugin)
}
public async installPlugin(pluginName: string, version?: string) { public async installPlugin(pluginName: string, version?: string) {
if (version) { if (version) {
const installedPlugin = await this.livePluginManager.install(pluginName, version); const installedPlugin = await this.livePluginManager.install(pluginName, version);

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
const pluginUtils = require('./shared'); const pluginUtils = require('./shared');

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
const pluginDefs = require('./plugin_defs'); const pluginDefs = require('./plugin_defs');

View file

@ -1,3 +1,4 @@
// @ts-nocheck
'use strict'; 'use strict';
const fs = require('fs').promises; const fs = require('fs').promises;

Some files were not shown because too many files have changed in this diff Show more