2021-01-21 21:06:52 +00:00
|
|
|
'use strict';
|
2011-05-30 15:53:11 +01:00
|
|
|
/**
|
|
|
|
* The pad object, defined with joose
|
|
|
|
*/
|
|
|
|
|
2021-11-28 23:39:15 -05:00
|
|
|
const AttributeMap = require('../../static/js/AttributeMap');
|
2021-01-21 21:06:52 +00:00
|
|
|
const Changeset = require('../../static/js/Changeset');
|
2021-10-26 00:56:27 -04:00
|
|
|
const ChatMessage = require('../../static/js/ChatMessage');
|
2021-01-21 21:06:52 +00:00
|
|
|
const AttributePool = require('../../static/js/AttributePool');
|
2021-12-01 16:39:01 -05:00
|
|
|
const Stream = require('../utils/Stream');
|
2021-11-27 03:37:34 -05:00
|
|
|
const assert = require('assert').strict;
|
2020-11-23 13:24:19 -05:00
|
|
|
const db = require('./DB');
|
|
|
|
const settings = require('../utils/Settings');
|
|
|
|
const authorManager = require('./AuthorManager');
|
|
|
|
const padManager = require('./PadManager');
|
|
|
|
const padMessageHandler = require('../handler/PadMessageHandler');
|
|
|
|
const groupManager = require('./GroupManager');
|
2021-01-21 21:06:52 +00:00
|
|
|
const CustomError = require('../utils/customError');
|
2020-11-23 13:24:19 -05:00
|
|
|
const readOnlyManager = require('./ReadOnlyManager');
|
|
|
|
const randomString = require('../utils/randomstring');
|
2021-01-21 21:06:52 +00:00
|
|
|
const hooks = require('../../static/js/pluginfw/hooks');
|
2022-02-16 22:20:18 -05:00
|
|
|
const {padutils: {warnDeprecated}} = require('../../static/js/pad_utils');
|
2020-11-23 13:24:19 -05:00
|
|
|
const promises = require('../utils/promises');
|
2011-05-16 16:30:21 +02:00
|
|
|
|
|
|
|
/**
|
2021-01-21 21:06:52 +00:00
|
|
|
* Copied from the Etherpad source code. It converts Windows line breaks to Unix
|
|
|
|
* line breaks and convert Tabs to spaces
|
2011-05-16 16:30:21 +02:00
|
|
|
* @param txt
|
|
|
|
*/
|
2021-01-21 21:06:52 +00:00
|
|
|
exports.cleanText = (txt) => txt.replace(/\r\n/g, '\n')
|
|
|
|
.replace(/\r/g, '\n')
|
|
|
|
.replace(/\t/g, ' ')
|
|
|
|
.replace(/\xa0/g, ' ');
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
class Pad {
|
|
|
|
/**
|
|
|
|
* @param [database] - Database object to access this pad's records (and only this pad's records;
|
|
|
|
* the shared global Etherpad database object is still used for all other pad accesses, such
|
|
|
|
* as copying the pad). Defaults to the shared global Etherpad database object. This parameter
|
|
|
|
* can be used to shard pad storage across multiple database backends, to put each pad in its
|
|
|
|
* own database table, or to validate imported pad data before it is written to the database.
|
|
|
|
*/
|
|
|
|
constructor(id, database = db) {
|
|
|
|
this.db = database;
|
|
|
|
this.atext = Changeset.makeAText('\n');
|
|
|
|
this.pool = new AttributePool();
|
|
|
|
this.head = -1;
|
|
|
|
this.chatHead = -1;
|
|
|
|
this.publicStatus = false;
|
|
|
|
this.id = id;
|
|
|
|
this.savedRevisions = [];
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
apool() {
|
|
|
|
return this.pool;
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getHeadRevisionNumber() {
|
|
|
|
return this.head;
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getSavedRevisionsNumber() {
|
|
|
|
return this.savedRevisions.length;
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getSavedRevisionsList() {
|
|
|
|
const savedRev = this.savedRevisions.map((rev) => rev.revNum);
|
|
|
|
savedRev.sort((a, b) => a - b);
|
|
|
|
return savedRev;
|
|
|
|
}
|
2015-02-24 23:42:35 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getPublicStatus() {
|
|
|
|
return this.publicStatus;
|
|
|
|
}
|
2015-02-24 23:42:35 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async appendRevision(aChangeset, authorId = '') {
|
|
|
|
const newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool);
|
|
|
|
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs) {
|
|
|
|
return this.head;
|
|
|
|
}
|
|
|
|
Changeset.copyAText(newAText, this.atext);
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
const newRev = ++this.head;
|
|
|
|
|
|
|
|
// ex. getNumForAuthor
|
|
|
|
if (authorId !== '') this.pool.putAttrib(['author', authorId]);
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-27 18:15:52 -05:00
|
|
|
const hook = this.head === 0 ? 'padCreate' : 'padUpdate';
|
|
|
|
await Promise.all([
|
2021-11-27 18:14:05 -05:00
|
|
|
this.db.set(`pad:${this.id}:revs:${newRev}`, {
|
|
|
|
changeset: aChangeset,
|
|
|
|
meta: {
|
|
|
|
author: authorId,
|
|
|
|
timestamp: Date.now(),
|
|
|
|
...newRev === this.getKeyRevisionNumber(newRev) ? {
|
|
|
|
pool: this.pool,
|
|
|
|
atext: this.atext,
|
|
|
|
} : {},
|
|
|
|
},
|
|
|
|
}),
|
2021-11-21 23:55:17 -05:00
|
|
|
this.saveToDatabase(),
|
2021-11-27 18:14:05 -05:00
|
|
|
authorId && authorManager.addPad(authorId, this.id),
|
2021-11-27 18:15:52 -05:00
|
|
|
hooks.aCallAll(hook, {
|
|
|
|
pad: this,
|
|
|
|
authorId,
|
|
|
|
get author() {
|
|
|
|
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
|
|
|
|
return this.authorId;
|
|
|
|
},
|
|
|
|
set author(authorId) {
|
|
|
|
warnDeprecated(`${hook} hook author context is deprecated; use authorId instead`);
|
|
|
|
this.authorId = authorId;
|
|
|
|
},
|
|
|
|
...this.head === 0 ? {} : {
|
|
|
|
revs: newRev,
|
|
|
|
changeset: aChangeset,
|
|
|
|
},
|
|
|
|
}),
|
2021-11-27 18:14:05 -05:00
|
|
|
]);
|
2021-11-21 23:55:17 -05:00
|
|
|
return newRev;
|
2012-01-30 15:59:13 +01:00
|
|
|
}
|
|
|
|
|
2021-11-22 19:40:00 -05:00
|
|
|
toJSON() {
|
|
|
|
const o = {...this, pool: this.pool.toJsonable()};
|
|
|
|
delete o.db;
|
|
|
|
delete o.id;
|
|
|
|
return o;
|
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// save all attributes to the database
|
|
|
|
async saveToDatabase() {
|
2021-11-22 19:40:00 -05:00
|
|
|
await this.db.set(`pad:${this.id}`, this);
|
2014-06-11 22:23:43 +02:00
|
|
|
}
|
2020-09-16 21:06:15 -04:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// get time of last edit (changeset application)
|
|
|
|
async getLastEdit() {
|
|
|
|
const revNum = this.getHeadRevisionNumber();
|
|
|
|
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getRevisionChangeset(revNum) {
|
|
|
|
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']);
|
|
|
|
}
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getRevisionAuthor(revNum) {
|
|
|
|
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']);
|
|
|
|
}
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getRevisionDate(revNum) {
|
|
|
|
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
|
|
|
}
|
|
|
|
|
2021-12-01 17:04:43 -05:00
|
|
|
/**
|
|
|
|
* @param {number} revNum - Must be a key revision number (see `getKeyRevisionNumber`).
|
|
|
|
* @returns The attribute text stored at `revNum`.
|
|
|
|
*/
|
|
|
|
async _getKeyRevisionAText(revNum) {
|
|
|
|
return await this.db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'atext']);
|
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getAllAuthors() {
|
|
|
|
const authorIds = [];
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
for (const key in this.pool.numToAttrib) {
|
|
|
|
if (this.pool.numToAttrib[key][0] === 'author' && this.pool.numToAttrib[key][1] !== '') {
|
|
|
|
authorIds.push(this.pool.numToAttrib[key][1]);
|
|
|
|
}
|
2012-02-29 11:56:31 -05:00
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
|
|
|
|
return authorIds;
|
2012-02-29 11:56:31 -05:00
|
|
|
}
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getInternalRevisionAText(targetRev) {
|
|
|
|
const keyRev = this.getKeyRevisionNumber(targetRev);
|
2021-12-01 16:39:01 -05:00
|
|
|
const [keyAText, changesets] = await Promise.all([
|
2021-12-01 17:04:43 -05:00
|
|
|
this._getKeyRevisionAText(keyRev),
|
2021-12-01 16:39:01 -05:00
|
|
|
Promise.all(
|
|
|
|
Stream.range(keyRev + 1, targetRev + 1).map(this.getRevisionChangeset.bind(this))),
|
|
|
|
]);
|
2021-11-21 23:55:17 -05:00
|
|
|
const apool = this.apool();
|
2021-12-01 16:39:01 -05:00
|
|
|
let atext = keyAText;
|
|
|
|
for (const cs of changesets) atext = Changeset.applyToAText(cs, atext, apool);
|
2021-11-21 23:55:17 -05:00
|
|
|
return atext;
|
2012-01-30 15:59:13 +01:00
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getRevision(revNum) {
|
|
|
|
return await this.db.get(`pad:${this.id}:revs:${revNum}`);
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async getAllAuthorColors() {
|
|
|
|
const authorIds = this.getAllAuthors();
|
|
|
|
const returnTable = {};
|
|
|
|
const colorPalette = authorManager.getColorPalette();
|
2013-01-22 23:37:53 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
await Promise.all(
|
|
|
|
authorIds.map((authorId) => authorManager.getAuthorColorId(authorId).then((colorId) => {
|
|
|
|
// colorId might be a hex color or an number out of the palette
|
|
|
|
returnTable[authorId] = colorPalette[colorId] || colorId;
|
|
|
|
})));
|
2019-02-01 09:57:50 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
return returnTable;
|
2019-01-31 11:14:38 +00:00
|
|
|
}
|
2013-01-22 23:37:53 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getValidRevisionRange(startRev, endRev) {
|
|
|
|
startRev = parseInt(startRev, 10);
|
|
|
|
const head = this.getHeadRevisionNumber();
|
|
|
|
endRev = endRev ? parseInt(endRev, 10) : head;
|
2019-01-31 11:14:38 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
if (isNaN(startRev) || startRev < 0 || startRev > head) {
|
|
|
|
startRev = null;
|
|
|
|
}
|
2019-02-08 23:20:57 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
if (isNaN(endRev) || endRev < startRev) {
|
|
|
|
endRev = null;
|
|
|
|
} else if (endRev > head) {
|
|
|
|
endRev = head;
|
|
|
|
}
|
2020-11-23 13:24:19 -05:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
if (startRev != null && endRev != null) {
|
|
|
|
return {startRev, endRev};
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
2019-01-31 11:14:38 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getKeyRevisionNumber(revNum) {
|
|
|
|
return Math.floor(revNum / 100) * 100;
|
|
|
|
}
|
2013-01-22 23:37:53 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* @returns {string} The pad's text.
|
|
|
|
*/
|
|
|
|
text() {
|
|
|
|
return this.atext.text;
|
|
|
|
}
|
2019-02-08 23:20:57 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* Splices text into the pad. If the result of the splice does not end with a newline, one will be
|
|
|
|
* automatically appended.
|
|
|
|
*
|
|
|
|
* @param {number} start - Location in pad text to start removing and inserting characters. Must
|
|
|
|
* be a non-negative integer less than or equal to `this.text().length`.
|
|
|
|
* @param {number} ndel - Number of characters to remove starting at `start`. Must be a
|
|
|
|
* non-negative integer less than or equal to `this.text().length - start`.
|
|
|
|
* @param {string} ins - New text to insert at `start` (after the `ndel` characters are deleted).
|
|
|
|
* @param {string} [authorId] - Author ID of the user making the change (if applicable).
|
|
|
|
*/
|
|
|
|
async spliceText(start, ndel, ins, authorId = '') {
|
|
|
|
if (start < 0) throw new RangeError(`start index must be non-negative (is ${start})`);
|
|
|
|
if (ndel < 0) throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);
|
|
|
|
const orig = this.text();
|
|
|
|
assert(orig.endsWith('\n'));
|
|
|
|
if (start + ndel > orig.length) throw new RangeError('start/delete past the end of the text');
|
|
|
|
ins = exports.cleanText(ins);
|
|
|
|
const willEndWithNewline =
|
|
|
|
start + ndel < orig.length || // Keeping last char (which is guaranteed to be a newline).
|
|
|
|
ins.endsWith('\n') ||
|
|
|
|
(!ins && start > 0 && orig[start - 1] === '\n');
|
|
|
|
if (!willEndWithNewline) ins += '\n';
|
|
|
|
if (ndel === 0 && ins.length === 0) return;
|
|
|
|
const changeset = Changeset.makeSplice(orig, start, ndel, ins);
|
|
|
|
await this.appendRevision(changeset, authorId);
|
2013-01-22 23:37:53 +00:00
|
|
|
}
|
2019-02-08 23:20:57 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* Replaces the pad's text with new text.
|
|
|
|
*
|
|
|
|
* @param {string} newText - The pad's new text. If this string does not end with a newline, one
|
|
|
|
* will be automatically appended.
|
|
|
|
* @param {string} [authorId] - The author ID of the user that initiated the change, if
|
|
|
|
* applicable.
|
|
|
|
*/
|
|
|
|
async setText(newText, authorId = '') {
|
|
|
|
await this.spliceText(0, this.text().length, newText, authorId);
|
2013-01-22 23:37:53 +00:00
|
|
|
}
|
2019-02-08 23:20:57 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* Appends text to the pad.
|
|
|
|
*
|
|
|
|
* @param {string} newText - Text to insert just BEFORE the pad's existing terminating newline.
|
|
|
|
* @param {string} [authorId] - The author ID of the user that initiated the change, if
|
|
|
|
* applicable.
|
|
|
|
*/
|
|
|
|
async appendText(newText, authorId = '') {
|
|
|
|
await this.spliceText(this.text().length - 1, 0, newText, authorId);
|
2013-01-22 23:37:53 +00:00
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* Adds a chat message to the pad, including saving it to the database.
|
|
|
|
*
|
|
|
|
* @param {(ChatMessage|string)} msgOrText - Either a chat message object (recommended) or a
|
|
|
|
* string containing the raw text of the user's chat message (deprecated).
|
|
|
|
* @param {?string} [authorId] - The user's author ID. Deprecated; use `msgOrText.authorId`
|
|
|
|
* instead.
|
|
|
|
* @param {?number} [time] - Message timestamp (milliseconds since epoch). Deprecated; use
|
|
|
|
* `msgOrText.time` instead.
|
|
|
|
*/
|
|
|
|
async appendChatMessage(msgOrText, authorId = null, time = null) {
|
|
|
|
const msg =
|
|
|
|
msgOrText instanceof ChatMessage ? msgOrText : new ChatMessage(msgOrText, authorId, time);
|
|
|
|
this.chatHead++;
|
|
|
|
await Promise.all([
|
|
|
|
// Don't save the display name in the database because the user can change it at any time. The
|
|
|
|
// `displayName` property will be populated with the current value when the message is read
|
|
|
|
// from the database.
|
|
|
|
this.db.set(`pad:${this.id}:chat:${this.chatHead}`, {...msg, displayName: undefined}),
|
|
|
|
this.saveToDatabase(),
|
|
|
|
]);
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* @param {number} entryNum - ID of the desired chat message.
|
|
|
|
* @returns {?ChatMessage}
|
|
|
|
*/
|
|
|
|
async getChatMessage(entryNum) {
|
|
|
|
const entry = await this.db.get(`pad:${this.id}:chat:${entryNum}`);
|
|
|
|
if (entry == null) return null;
|
|
|
|
const message = ChatMessage.fromObject(entry);
|
|
|
|
message.displayName = await authorManager.getAuthorName(message.authorId);
|
|
|
|
return message;
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
/**
|
|
|
|
* @param {number} start - ID of the first desired chat message.
|
|
|
|
* @param {number} end - ID of the last desired chat message.
|
|
|
|
* @returns {ChatMessage[]} Any existing messages with IDs between `start` (inclusive) and `end`
|
|
|
|
* (inclusive), in order. Note: `start` and `end` form a closed interval, not a half-open
|
|
|
|
* interval as is typical in code.
|
|
|
|
*/
|
|
|
|
async getChatMessages(start, end) {
|
2022-04-15 02:50:25 -04:00
|
|
|
const entries =
|
|
|
|
await Promise.all(Stream.range(start, end + 1).map(this.getChatMessage.bind(this)));
|
2021-11-21 23:55:17 -05:00
|
|
|
|
|
|
|
// sort out broken chat entries
|
|
|
|
// it looks like in happened in the past that the chat head was
|
|
|
|
// incremented, but the chat message wasn't added
|
|
|
|
return entries.filter((entry) => {
|
|
|
|
const pass = (entry != null);
|
|
|
|
if (!pass) {
|
|
|
|
console.warn(`WARNING: Found broken chat entry in pad ${this.id}`);
|
|
|
|
}
|
|
|
|
return pass;
|
|
|
|
});
|
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async init(text, authorId = '') {
|
|
|
|
// try to load the pad
|
|
|
|
const value = await this.db.get(`pad:${this.id}`);
|
|
|
|
|
|
|
|
// if this pad exists, load it
|
|
|
|
if (value != null) {
|
2021-11-22 19:40:00 -05:00
|
|
|
Object.assign(this, value);
|
|
|
|
if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);
|
2021-11-21 23:55:17 -05:00
|
|
|
} else {
|
|
|
|
if (text == null) {
|
|
|
|
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
|
|
|
|
await hooks.aCallAll('padDefaultContent', context);
|
|
|
|
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
|
|
|
|
text = exports.cleanText(context.content);
|
|
|
|
}
|
|
|
|
const firstChangeset = Changeset.makeSplice('\n', 0, 0, text);
|
|
|
|
await this.appendRevision(firstChangeset, authorId);
|
|
|
|
}
|
|
|
|
await hooks.aCallAll('padLoad', {pad: this});
|
|
|
|
}
|
2021-12-09 20:41:22 -08:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async copy(destinationID, force) {
|
|
|
|
// Kick everyone from this pad.
|
|
|
|
// This was commented due to https://github.com/ether/etherpad-lite/issues/3183.
|
|
|
|
// Do we really need to kick everyone out?
|
|
|
|
// padMessageHandler.kickSessionsFromPad(sourceID);
|
|
|
|
|
|
|
|
// flush the source pad:
|
|
|
|
await this.saveToDatabase();
|
|
|
|
|
|
|
|
// if it's a group pad, let's make sure the group exists.
|
|
|
|
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
|
|
|
|
|
|
|
// if force is true and already exists a Pad with the same id, remove that Pad
|
|
|
|
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
|
|
|
|
|
|
|
const copyRecord = async (keySuffix) => {
|
|
|
|
const val = await this.db.get(`pad:${this.id}${keySuffix}`);
|
|
|
|
await db.set(`pad:${destinationID}${keySuffix}`, val);
|
|
|
|
};
|
|
|
|
|
2022-04-15 02:57:43 -04:00
|
|
|
const promises = (function* () {
|
2021-11-21 23:55:17 -05:00
|
|
|
yield copyRecord('');
|
2022-04-15 02:50:25 -04:00
|
|
|
yield* Stream.range(0, this.head + 1).map((i) => copyRecord(`:revs:${i}`));
|
|
|
|
yield* Stream.range(0, this.chatHead + 1).map((i) => copyRecord(`:chat:${i}`));
|
2021-11-21 23:55:17 -05:00
|
|
|
yield this.copyAuthorInfoToDestinationPad(destinationID);
|
|
|
|
if (destGroupID) yield db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
|
2022-04-15 02:57:43 -04:00
|
|
|
}).call(this);
|
|
|
|
for (const p of new Stream(promises).batch(100).buffer(99)) await p;
|
2021-11-21 23:55:17 -05:00
|
|
|
|
|
|
|
// Initialize the new pad (will update the listAllPads cache)
|
|
|
|
const dstPad = await padManager.getPad(destinationID, null);
|
|
|
|
|
|
|
|
// let the plugins know the pad was copied
|
|
|
|
await hooks.aCallAll('padCopy', {
|
|
|
|
get originalPad() {
|
|
|
|
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
|
|
|
|
return this.srcPad;
|
|
|
|
},
|
|
|
|
get destinationID() {
|
|
|
|
warnDeprecated(
|
|
|
|
'padCopy destinationID context property is deprecated; use dstPad.id instead');
|
|
|
|
return this.dstPad.id;
|
|
|
|
},
|
|
|
|
srcPad: this,
|
|
|
|
dstPad,
|
|
|
|
});
|
|
|
|
|
|
|
|
return {padID: destinationID};
|
|
|
|
}
|
2015-10-19 12:58:47 -04:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async checkIfGroupExistAndReturnIt(destinationID) {
|
|
|
|
let destGroupID = false;
|
2012-01-30 15:59:13 +01:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
if (destinationID.indexOf('$') >= 0) {
|
|
|
|
destGroupID = destinationID.split('$')[0];
|
|
|
|
const groupExists = await groupManager.doesGroupExist(destGroupID);
|
2019-01-31 11:14:38 +00:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// group does not exist
|
|
|
|
if (!groupExists) {
|
|
|
|
throw new CustomError('groupID does not exist for destinationID', 'apierror');
|
2012-02-29 11:56:31 -05:00
|
|
|
}
|
2012-01-30 15:59:13 +01:00
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
return destGroupID;
|
|
|
|
}
|
|
|
|
|
|
|
|
async removePadIfForceIsTrueAndAlreadyExist(destinationID, force) {
|
|
|
|
// if the pad exists, we should abort, unless forced.
|
|
|
|
const exists = await padManager.doesPadExist(destinationID);
|
|
|
|
|
|
|
|
// allow force to be a string
|
|
|
|
if (typeof force === 'string') {
|
|
|
|
force = (force.toLowerCase() === 'true');
|
|
|
|
} else {
|
|
|
|
force = !!force;
|
2022-02-16 14:42:24 -05:00
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
|
|
|
|
if (exists) {
|
|
|
|
if (!force) {
|
|
|
|
console.error('erroring out without force');
|
|
|
|
throw new CustomError('destinationID already exists', 'apierror');
|
|
|
|
}
|
|
|
|
|
|
|
|
// exists and forcing
|
|
|
|
const pad = await padManager.getPad(destinationID);
|
|
|
|
await pad.remove();
|
2020-09-16 15:24:09 -03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async copyAuthorInfoToDestinationPad(destinationID) {
|
|
|
|
// add the new sourcePad to all authors who contributed to the old one
|
|
|
|
await Promise.all(this.getAllAuthors().map(
|
|
|
|
(authorID) => authorManager.addPad(authorID, destinationID)));
|
2020-09-16 15:24:09 -03:00
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async copyPadWithoutHistory(destinationID, force, authorId = '') {
|
|
|
|
// flush the source pad
|
|
|
|
this.saveToDatabase();
|
|
|
|
|
|
|
|
// if it's a group pad, let's make sure the group exists.
|
|
|
|
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
|
|
|
|
|
|
|
// if force is true and already exists a Pad with the same id, remove that Pad
|
|
|
|
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
|
|
|
|
|
|
|
await this.copyAuthorInfoToDestinationPad(destinationID);
|
|
|
|
|
|
|
|
// Group pad? Add it to the group's list
|
|
|
|
if (destGroupID) {
|
|
|
|
await db.setSub(`group:${destGroupID}`, ['pads', destinationID], 1);
|
2020-09-16 15:24:09 -03:00
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// initialize the pad with a new line to avoid getting the defaultText
|
|
|
|
const dstPad = await padManager.getPad(destinationID, '\n', authorId);
|
|
|
|
dstPad.pool = this.pool.clone();
|
|
|
|
|
|
|
|
const oldAText = this.atext;
|
|
|
|
|
|
|
|
// based on Changeset.makeSplice
|
|
|
|
const assem = Changeset.smartOpAssembler();
|
|
|
|
for (const op of Changeset.opsFromAText(oldAText)) assem.append(op);
|
|
|
|
assem.endDocument();
|
|
|
|
|
|
|
|
// although we have instantiated the dstPad with '\n', an additional '\n' is
|
|
|
|
// added internally, so the pad text on the revision 0 is "\n\n"
|
|
|
|
const oldLength = 2;
|
|
|
|
|
|
|
|
const newLength = assem.getLengthChange();
|
|
|
|
const newText = oldAText.text;
|
|
|
|
|
|
|
|
// create a changeset that removes the previous text and add the newText with
|
|
|
|
// all atributes present on the source pad
|
|
|
|
const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText);
|
|
|
|
dstPad.appendRevision(changeset, authorId);
|
|
|
|
|
|
|
|
await hooks.aCallAll('padCopy', {
|
|
|
|
get originalPad() {
|
|
|
|
warnDeprecated('padCopy originalPad context property is deprecated; use srcPad instead');
|
|
|
|
return this.srcPad;
|
|
|
|
},
|
|
|
|
get destinationID() {
|
|
|
|
warnDeprecated(
|
|
|
|
'padCopy destinationID context property is deprecated; use dstPad.id instead');
|
|
|
|
return this.dstPad.id;
|
|
|
|
},
|
|
|
|
srcPad: this,
|
|
|
|
dstPad,
|
|
|
|
});
|
|
|
|
|
|
|
|
return {padID: destinationID};
|
2020-09-16 15:24:09 -03:00
|
|
|
}
|
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async remove() {
|
|
|
|
const padID = this.id;
|
|
|
|
const p = [];
|
2020-09-16 15:24:09 -03:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// kick everyone from this pad
|
|
|
|
padMessageHandler.kickSessionsFromPad(padID);
|
2020-09-16 15:24:09 -03:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// delete all relations - the original code used async.parallel but
|
|
|
|
// none of the operations except getting the group depended on callbacks
|
|
|
|
// so the database operations here are just started and then left to
|
|
|
|
// run to completion
|
2020-09-16 15:24:09 -03:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// is it a group pad? -> delete the entry of this pad in the group
|
|
|
|
if (padID.indexOf('$') >= 0) {
|
|
|
|
// it is a group pad
|
|
|
|
const groupID = padID.substring(0, padID.indexOf('$'));
|
|
|
|
const group = await db.get(`group:${groupID}`);
|
2020-09-16 15:24:09 -03:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// remove the pad entry
|
|
|
|
delete group.pads[padID];
|
|
|
|
|
|
|
|
// set the new value
|
|
|
|
p.push(db.set(`group:${groupID}`, group));
|
2012-02-29 14:40:14 -05:00
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
|
|
|
|
// remove the readonly entries
|
|
|
|
p.push(readOnlyManager.getReadOnlyId(padID).then(async (readonlyID) => {
|
|
|
|
await db.remove(`readonly2pad:${readonlyID}`);
|
|
|
|
}));
|
|
|
|
p.push(db.remove(`pad2readonly:${padID}`));
|
|
|
|
|
|
|
|
// delete all chat messages
|
|
|
|
p.push(promises.timesLimit(this.chatHead + 1, 500, async (i) => {
|
|
|
|
await this.db.remove(`pad:${this.id}:chat:${i}`, null);
|
|
|
|
}));
|
|
|
|
|
|
|
|
// delete all revisions
|
|
|
|
p.push(promises.timesLimit(this.head + 1, 500, async (i) => {
|
|
|
|
await this.db.remove(`pad:${this.id}:revs:${i}`, null);
|
|
|
|
}));
|
|
|
|
|
|
|
|
// remove pad from all authors who contributed
|
|
|
|
this.getAllAuthors().forEach((authorId) => {
|
|
|
|
p.push(authorManager.removePad(authorId, padID));
|
|
|
|
});
|
|
|
|
|
|
|
|
// delete the pad entry and delete pad from padManager
|
|
|
|
p.push(padManager.removePad(padID));
|
|
|
|
p.push(hooks.aCallAll('padRemove', {
|
|
|
|
get padID() {
|
|
|
|
warnDeprecated('padRemove padID context property is deprecated; use pad.id instead');
|
|
|
|
return this.pad.id;
|
|
|
|
},
|
|
|
|
pad: this,
|
|
|
|
}));
|
|
|
|
await Promise.all(p);
|
2012-02-29 14:40:14 -05:00
|
|
|
}
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// set in db
|
|
|
|
async setPublicStatus(publicStatus) {
|
|
|
|
this.publicStatus = publicStatus;
|
|
|
|
await this.saveToDatabase();
|
|
|
|
}
|
2014-06-11 22:23:43 +02:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
async addSavedRevision(revNum, savedById, label) {
|
|
|
|
// if this revision is already saved, return silently
|
|
|
|
for (const i in this.savedRevisions) {
|
|
|
|
if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2012-02-29 14:40:14 -05:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
// build the saved revision object
|
|
|
|
const savedRevision = {};
|
|
|
|
savedRevision.revNum = revNum;
|
|
|
|
savedRevision.savedById = savedById;
|
|
|
|
savedRevision.label = label || `Revision ${revNum}`;
|
|
|
|
savedRevision.timestamp = Date.now();
|
|
|
|
savedRevision.id = randomString(10);
|
|
|
|
|
|
|
|
// save this new saved revision
|
|
|
|
this.savedRevisions.push(savedRevision);
|
|
|
|
await this.saveToDatabase();
|
|
|
|
}
|
2021-11-27 03:37:34 -05:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
getSavedRevisions() {
|
|
|
|
return this.savedRevisions;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Asserts that all pad data is consistent. Throws if inconsistent.
|
|
|
|
*/
|
|
|
|
async check() {
|
|
|
|
assert(this.id != null);
|
|
|
|
assert.equal(typeof this.id, 'string');
|
|
|
|
|
|
|
|
const head = this.getHeadRevisionNumber();
|
2021-12-01 18:20:44 -05:00
|
|
|
assert(head != null);
|
2021-11-21 23:55:17 -05:00
|
|
|
assert(Number.isInteger(head));
|
|
|
|
assert(head >= -1);
|
|
|
|
|
|
|
|
const savedRevisionsList = this.getSavedRevisionsList();
|
|
|
|
assert(Array.isArray(savedRevisionsList));
|
|
|
|
assert.equal(this.getSavedRevisionsNumber(), savedRevisionsList.length);
|
|
|
|
let prevSavedRev = null;
|
|
|
|
for (const rev of savedRevisionsList) {
|
2021-12-01 18:20:44 -05:00
|
|
|
assert(rev != null);
|
2021-11-21 23:55:17 -05:00
|
|
|
assert(Number.isInteger(rev));
|
|
|
|
assert(rev >= 0);
|
|
|
|
assert(rev <= head);
|
|
|
|
assert(prevSavedRev == null || rev > prevSavedRev);
|
|
|
|
prevSavedRev = rev;
|
|
|
|
}
|
|
|
|
const savedRevisions = this.getSavedRevisions();
|
|
|
|
assert(Array.isArray(savedRevisions));
|
|
|
|
assert.equal(savedRevisions.length, savedRevisionsList.length);
|
|
|
|
const savedRevisionsIds = new Set();
|
|
|
|
for (const savedRev of savedRevisions) {
|
|
|
|
assert(savedRev != null);
|
|
|
|
assert.equal(typeof savedRev, 'object');
|
|
|
|
assert(savedRevisionsList.includes(savedRev.revNum));
|
|
|
|
assert(savedRev.id != null);
|
|
|
|
assert.equal(typeof savedRev.id, 'string');
|
|
|
|
assert(!savedRevisionsIds.has(savedRev.id));
|
|
|
|
savedRevisionsIds.add(savedRev.id);
|
|
|
|
}
|
|
|
|
|
|
|
|
const pool = this.apool();
|
|
|
|
assert(pool instanceof AttributePool);
|
|
|
|
await pool.check();
|
|
|
|
|
|
|
|
const authorIds = new Set();
|
|
|
|
pool.eachAttrib((k, v) => {
|
|
|
|
if (k === 'author' && v) authorIds.add(v);
|
|
|
|
});
|
|
|
|
let atext = Changeset.makeAText('\n');
|
2021-12-01 18:42:27 -05:00
|
|
|
for (let r = 0; r <= head; ++r) {
|
|
|
|
try {
|
2021-11-21 23:55:17 -05:00
|
|
|
const [changeset, authorId, timestamp] = await Promise.all([
|
|
|
|
this.getRevisionChangeset(r),
|
|
|
|
this.getRevisionAuthor(r),
|
|
|
|
this.getRevisionDate(r),
|
|
|
|
]);
|
|
|
|
assert(authorId != null);
|
|
|
|
assert.equal(typeof authorId, 'string');
|
|
|
|
if (authorId) authorIds.add(authorId);
|
|
|
|
assert(timestamp != null);
|
|
|
|
assert.equal(typeof timestamp, 'number');
|
|
|
|
assert(timestamp > 0);
|
|
|
|
assert(changeset != null);
|
|
|
|
assert.equal(typeof changeset, 'string');
|
|
|
|
Changeset.checkRep(changeset);
|
|
|
|
const unpacked = Changeset.unpack(changeset);
|
|
|
|
let text = atext.text;
|
|
|
|
for (const op of Changeset.deserializeOps(unpacked.ops)) {
|
|
|
|
if (['=', '-'].includes(op.opcode)) {
|
|
|
|
assert(text.length >= op.chars);
|
|
|
|
const consumed = text.slice(0, op.chars);
|
|
|
|
const nlines = (consumed.match(/\n/g) || []).length;
|
|
|
|
assert.equal(op.lines, nlines);
|
|
|
|
if (op.lines > 0) assert(consumed.endsWith('\n'));
|
|
|
|
text = text.slice(op.chars);
|
|
|
|
}
|
|
|
|
assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString());
|
2021-11-27 03:37:34 -05:00
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
atext = Changeset.applyToAText(changeset, atext, pool);
|
2021-12-01 17:05:23 -05:00
|
|
|
if (r === this.getKeyRevisionNumber(r)) {
|
|
|
|
assert.deepEqual(await this._getKeyRevisionAText(r), atext);
|
|
|
|
}
|
2021-12-01 18:42:27 -05:00
|
|
|
} catch (err) {
|
|
|
|
err.message = `(pad ${this.id} revision ${r}) ${err.message}`;
|
|
|
|
throw err;
|
2021-11-27 03:37:34 -05:00
|
|
|
}
|
|
|
|
}
|
2021-11-21 23:55:17 -05:00
|
|
|
assert.equal(this.text(), atext.text);
|
|
|
|
assert.deepEqual(this.atext, atext);
|
|
|
|
assert.deepEqual(this.getAllAuthors().sort(), [...authorIds].sort());
|
|
|
|
|
2021-12-01 18:20:44 -05:00
|
|
|
assert(this.chatHead != null);
|
2021-11-21 23:55:17 -05:00
|
|
|
assert(Number.isInteger(this.chatHead));
|
|
|
|
assert(this.chatHead >= -1);
|
2021-12-01 18:42:27 -05:00
|
|
|
for (c = 0; c <= this.chatHead; ++c) {
|
|
|
|
try {
|
2021-11-21 23:55:17 -05:00
|
|
|
const msg = await this.getChatMessage(c);
|
|
|
|
assert(msg != null);
|
|
|
|
assert(msg instanceof ChatMessage);
|
2021-12-01 18:42:27 -05:00
|
|
|
} catch (err) {
|
|
|
|
err.message = `(pad ${this.id} chat message ${c}) ${err.message}`;
|
|
|
|
throw err;
|
2021-11-21 23:55:17 -05:00
|
|
|
}
|
2021-11-27 03:37:34 -05:00
|
|
|
}
|
2022-04-08 03:07:36 -04:00
|
|
|
|
2021-11-21 23:55:17 -05:00
|
|
|
await hooks.aCallAll('padCheck', {pad: this});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
exports.Pad = Pad;
|