|
@@ -26,7 +26,7 @@ const debug = require('debug')('growi:services:page');
|
|
|
|
|
|
|
|
const logger = loggerFactory('growi:services:page');
|
|
const logger = loggerFactory('growi:services:page');
|
|
|
const {
|
|
const {
|
|
|
- isCreatablePage, isTrashPage, collectAncestorPaths, isTopPage,
|
|
|
|
|
|
|
+ isCreatablePage, isTrashPage, isTopPage, isDeletablePage, omitDuplicateAreaPathFromPaths,
|
|
|
} = pagePathUtils;
|
|
} = pagePathUtils;
|
|
|
|
|
|
|
|
const BULK_REINDEX_SIZE = 100;
|
|
const BULK_REINDEX_SIZE = 100;
|
|
@@ -212,21 +212,24 @@ class PageService {
|
|
|
|
|
|
|
|
const Page = this.crowi.model('Page');
|
|
const Page = this.crowi.model('Page');
|
|
|
|
|
|
|
|
|
|
+ let pagePath = path;
|
|
|
|
|
+
|
|
|
let page;
|
|
let page;
|
|
|
if (pageId != null) { // prioritized
|
|
if (pageId != null) { // prioritized
|
|
|
page = await Page.findByIdAndViewer(pageId, user);
|
|
page = await Page.findByIdAndViewer(pageId, user);
|
|
|
|
|
+ pagePath = page.path;
|
|
|
}
|
|
}
|
|
|
else {
|
|
else {
|
|
|
- page = await Page.findByPathAndViewer(path, user);
|
|
|
|
|
|
|
+ page = await Page.findByPathAndViewer(pagePath, user);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const result: any = {};
|
|
const result: any = {};
|
|
|
|
|
|
|
|
if (page == null) {
|
|
if (page == null) {
|
|
|
- const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
|
|
|
|
|
|
|
+ const isExist = await Page.count({ $or: [{ _id: pageId }, { pat: pagePath }] }) > 0;
|
|
|
result.isForbidden = isExist;
|
|
result.isForbidden = isExist;
|
|
|
result.isNotFound = !isExist;
|
|
result.isNotFound = !isExist;
|
|
|
- result.isCreatable = isCreatablePage(path);
|
|
|
|
|
|
|
+ result.isCreatable = isCreatablePage(pagePath);
|
|
|
result.page = page;
|
|
result.page = page;
|
|
|
|
|
|
|
|
return result;
|
|
return result;
|
|
@@ -236,6 +239,7 @@ class PageService {
|
|
|
result.isForbidden = false;
|
|
result.isForbidden = false;
|
|
|
result.isNotFound = false;
|
|
result.isNotFound = false;
|
|
|
result.isCreatable = false;
|
|
result.isCreatable = false;
|
|
|
|
|
+ result.isDeletable = isDeletablePage(pagePath);
|
|
|
result.isDeleted = page.isDeleted();
|
|
result.isDeleted = page.isDeleted();
|
|
|
|
|
|
|
|
return result;
|
|
return result;
|
|
@@ -268,6 +272,20 @@ class PageService {
|
|
|
return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
|
|
return page.grant !== Page.GRANT_RESTRICTED && page.grant !== Page.GRANT_SPECIFIED;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Remove all empty pages at leaf position by page whose parent will change or which will be deleted.
|
|
|
|
|
+ * @param page Page whose parent will change or which will be deleted
|
|
|
|
|
+ */
|
|
|
|
|
+ async removeLeafEmptyPages(page): Promise<void> {
|
|
|
|
|
+ const Page = mongoose.model('Page') as unknown as PageModel;
|
|
|
|
|
+
|
|
|
|
|
+ // delete leaf empty pages
|
|
|
|
|
+ const shouldDeleteLeafEmptyPages = !(await Page.exists({ parent: page.parent, _id: { $ne: page._id } }));
|
|
|
|
|
+ if (shouldDeleteLeafEmptyPages) {
|
|
|
|
|
+ await Page.removeLeafEmptyPagesById(page.parent);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Generate read stream to operate descendants of the specified page path
|
|
* Generate read stream to operate descendants of the specified page path
|
|
|
* @param {string} targetPagePath
|
|
* @param {string} targetPagePath
|
|
@@ -356,15 +374,39 @@ class PageService {
|
|
|
update.lastUpdateUser = user;
|
|
update.lastUpdateUser = user;
|
|
|
update.updatedAt = new Date();
|
|
update.updatedAt = new Date();
|
|
|
}
|
|
}
|
|
|
- const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
|
|
|
|
|
|
|
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ // * before rename target page
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ const oldPageParentId = page.parent; // this is used to update descendantCount of old page's ancestors
|
|
|
|
|
+
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ // * rename target page
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
|
|
|
this.pageEvent.emit('rename', page, user);
|
|
this.pageEvent.emit('rename', page, user);
|
|
|
|
|
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ // * after rename target page
|
|
|
|
|
+ // *************************
|
|
|
|
|
+ // rename descendants and update descendantCount asynchronously
|
|
|
|
|
+ this.resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId);
|
|
|
|
|
+
|
|
|
|
|
+ return renamedPage;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async resumableRenameDescendants(page, newPagePath, user, options, shouldUseV4Process, renamedPage, oldPageParentId) {
|
|
|
// TODO: resume
|
|
// TODO: resume
|
|
|
// update descendants first
|
|
// update descendants first
|
|
|
- this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
|
|
|
|
|
|
|
+ await this.renameDescendantsWithStream(page, newPagePath, user, options, shouldUseV4Process);
|
|
|
|
|
|
|
|
- return renamedPage;
|
|
|
|
|
|
|
+ // reduce ancestore's descendantCount
|
|
|
|
|
+ const nToReduce = -1 * ((page.isEmpty ? 0 : 1) + page.descendantCount);
|
|
|
|
|
+ await this.updateDescendantCountOfAncestors(oldPageParentId, nToReduce, true);
|
|
|
|
|
+
|
|
|
|
|
+ // increase ancestore's descendantCount
|
|
|
|
|
+ const nToIncrease = (renamedPage.isEmpty ? 0 : 1) + page.descendantCount;
|
|
|
|
|
+ await this.updateDescendantCountOfAncestors(renamedPage._id, nToIncrease, false);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// !!renaming always include descendant pages!!
|
|
// !!renaming always include descendant pages!!
|
|
@@ -676,9 +718,17 @@ class PageService {
|
|
|
|
|
|
|
|
newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
|
|
newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
|
|
|
|
|
|
|
|
- const createdPage = await (Page.create as CreateMethod)(
|
|
|
|
|
- newPagePath, page.revision.body, user, options,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ let createdPage;
|
|
|
|
|
+
|
|
|
|
|
+ if (page.isEmpty) {
|
|
|
|
|
+ const parent = await Page.getParentAndFillAncestors(newPagePath);
|
|
|
|
|
+ createdPage = await Page.createEmptyPage(newPagePath, parent);
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ createdPage = await (Page.create as CreateMethod)(
|
|
|
|
|
+ newPagePath, page.revision.body, user, options,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// take over tags
|
|
// take over tags
|
|
|
const originTags = await page.findRelatedTagsById();
|
|
const originTags = await page.findRelatedTagsById();
|
|
@@ -694,12 +744,16 @@ class PageService {
|
|
|
|
|
|
|
|
// TODO: resume
|
|
// TODO: resume
|
|
|
if (isRecursively) {
|
|
if (isRecursively) {
|
|
|
- this.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
|
|
|
|
|
|
|
+ this.resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPage._id);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async resumableDuplicateDescendants(page, newPagePath, user, shouldUseV4Process, createdPageId) {
|
|
|
|
|
+ const descendantCountAppliedToAncestors = await this.duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process);
|
|
|
|
|
+ await this.updateDescendantCountOfAncestors(createdPageId, descendantCountAppliedToAncestors, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async duplicateV4(page, newPagePath, user, isRecursively) {
|
|
async duplicateV4(page, newPagePath, user, isRecursively) {
|
|
|
const Page = this.crowi.model('Page');
|
|
const Page = this.crowi.model('Page');
|
|
|
const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
|
|
const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
|
|
@@ -803,14 +857,7 @@ class PageService {
|
|
|
pageIdMapping[page._id] = newPageId;
|
|
pageIdMapping[page._id] = newPageId;
|
|
|
|
|
|
|
|
let newPage;
|
|
let newPage;
|
|
|
- if (page.isEmpty) {
|
|
|
|
|
- newPage = {
|
|
|
|
|
- _id: newPageId,
|
|
|
|
|
- path: newPagePath,
|
|
|
|
|
- isEmpty: true,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- else {
|
|
|
|
|
|
|
+ if (!page.isEmpty) {
|
|
|
newPage = {
|
|
newPage = {
|
|
|
_id: newPageId,
|
|
_id: newPageId,
|
|
|
path: newPagePath,
|
|
path: newPagePath,
|
|
@@ -821,14 +868,11 @@ class PageService {
|
|
|
lastUpdateUser: user._id,
|
|
lastUpdateUser: user._id,
|
|
|
revision: revisionId,
|
|
revision: revisionId,
|
|
|
};
|
|
};
|
|
|
|
|
+ newRevisions.push({
|
|
|
|
|
+ _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
newPages.push(newPage);
|
|
newPages.push(newPage);
|
|
|
-
|
|
|
|
|
- newRevisions.push({
|
|
|
|
|
- _id: revisionId, pageId: newPageId, body: pageIdRevisionMapping[page._id].body, author: user._id, format: 'markdown',
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
await Page.insertMany(newPages, { ordered: false });
|
|
await Page.insertMany(newPages, { ordered: false });
|
|
@@ -898,11 +942,13 @@ class PageService {
|
|
|
const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
|
|
const normalizeParentAndDescendantCountOfDescendants = this.normalizeParentAndDescendantCountOfDescendants.bind(this);
|
|
|
const pageEvent = this.pageEvent;
|
|
const pageEvent = this.pageEvent;
|
|
|
let count = 0;
|
|
let count = 0;
|
|
|
|
|
+ let nNonEmptyDuplicatedPages = 0;
|
|
|
const writeStream = new Writable({
|
|
const writeStream = new Writable({
|
|
|
objectMode: true,
|
|
objectMode: true,
|
|
|
async write(batch, encoding, callback) {
|
|
async write(batch, encoding, callback) {
|
|
|
try {
|
|
try {
|
|
|
count += batch.length;
|
|
count += batch.length;
|
|
|
|
|
+ nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
|
|
|
await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
|
|
await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
|
|
|
logger.debug(`Adding pages progressing: (count=${count})`);
|
|
logger.debug(`Adding pages progressing: (count=${count})`);
|
|
|
}
|
|
}
|
|
@@ -938,6 +984,9 @@ class PageService {
|
|
|
.pipe(createBatchStream(BULK_REINDEX_SIZE))
|
|
.pipe(createBatchStream(BULK_REINDEX_SIZE))
|
|
|
.pipe(writeStream);
|
|
.pipe(writeStream);
|
|
|
|
|
|
|
|
|
|
+ await streamToPromise(writeStream);
|
|
|
|
|
+
|
|
|
|
|
+ return nNonEmptyDuplicatedPages;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
|
|
private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
|
|
@@ -976,6 +1025,9 @@ class PageService {
|
|
|
.pipe(createBatchStream(BULK_REINDEX_SIZE))
|
|
.pipe(createBatchStream(BULK_REINDEX_SIZE))
|
|
|
.pipe(writeStream);
|
|
.pipe(writeStream);
|
|
|
|
|
|
|
|
|
|
+ await streamToPromise(writeStream);
|
|
|
|
|
+
|
|
|
|
|
+ return count;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
/*
|
|
@@ -1014,10 +1066,8 @@ class PageService {
|
|
|
// update descendantCount of ancestors'
|
|
// update descendantCount of ancestors'
|
|
|
await this.updateDescendantCountOfAncestors(page.parent, -1, true);
|
|
await this.updateDescendantCountOfAncestors(page.parent, -1, true);
|
|
|
|
|
|
|
|
- const shouldDeleteLeafEmptyPages = !shouldReplace;
|
|
|
|
|
- if (shouldDeleteLeafEmptyPages) {
|
|
|
|
|
- // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // delete leaf empty pages
|
|
|
|
|
+ await this.removeLeafEmptyPages(page);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let deletedPage;
|
|
let deletedPage;
|
|
@@ -1050,7 +1100,8 @@ class PageService {
|
|
|
if (page.parent != null) {
|
|
if (page.parent != null) {
|
|
|
await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
|
|
await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
|
|
|
|
|
|
|
|
- // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
|
|
|
|
|
|
|
+ // delete leaf empty pages
|
|
|
|
|
+ await this.removeLeafEmptyPages(page);
|
|
|
}
|
|
}
|
|
|
})();
|
|
})();
|
|
|
}
|
|
}
|
|
@@ -1281,10 +1332,11 @@ class PageService {
|
|
|
|
|
|
|
|
if (!isRecursively) {
|
|
if (!isRecursively) {
|
|
|
await this.updateDescendantCountOfAncestors(page.parent, -1, true);
|
|
await this.updateDescendantCountOfAncestors(page.parent, -1, true);
|
|
|
-
|
|
|
|
|
- // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // delete leaf empty pages
|
|
|
|
|
+ await this.removeLeafEmptyPages(page);
|
|
|
|
|
+
|
|
|
if (!page.isEmpty && !preventEmitting) {
|
|
if (!page.isEmpty && !preventEmitting) {
|
|
|
this.pageEvent.emit('deleteCompletely', page, user);
|
|
this.pageEvent.emit('deleteCompletely', page, user);
|
|
|
}
|
|
}
|
|
@@ -1299,8 +1351,6 @@ class PageService {
|
|
|
if (page.parent != null) {
|
|
if (page.parent != null) {
|
|
|
await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
|
|
await this.updateDescendantCountOfAncestors(page.parent, (deletedDescendantCount + 1) * -1, true);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
|
|
|
|
|
})();
|
|
})();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1460,7 +1510,8 @@ class PageService {
|
|
|
if (page.parent != null) {
|
|
if (page.parent != null) {
|
|
|
await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
|
|
await this.updateDescendantCountOfAncestors(page.parent, revertedDescendantCount + 1, true);
|
|
|
|
|
|
|
|
- // TODO https://redmine.weseek.co.jp/issues/87667 : delete leaf empty pages here
|
|
|
|
|
|
|
+ // delete leaf empty pages
|
|
|
|
|
+ await this.removeLeafEmptyPages(page);
|
|
|
}
|
|
}
|
|
|
})();
|
|
})();
|
|
|
}
|
|
}
|
|
@@ -1798,8 +1849,28 @@ class PageService {
|
|
|
// socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
|
|
// socket.emit('normalizeParentRecursivelyByPageIds', { error: err.message }); TODO: use socket to tell user
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // generate regexps
|
|
|
|
|
- const regexps = await this._generateRegExpsByPageIds(normalizedIds);
|
|
|
|
|
|
|
+ /*
|
|
|
|
|
+ * generate regexps
|
|
|
|
|
+ */
|
|
|
|
|
+ const Page = mongoose.model('Page') as unknown as PageModel;
|
|
|
|
|
+
|
|
|
|
|
+ let result;
|
|
|
|
|
+ try {
|
|
|
|
|
+ result = await Page.findListByPageIds(pageIds, null, false);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (err) {
|
|
|
|
|
+ logger.error('Failed to find pages by ids', err);
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ const { pages } = result;
|
|
|
|
|
+
|
|
|
|
|
+ // prepare no duplicated area paths
|
|
|
|
|
+ let paths = pages.map(p => p.path);
|
|
|
|
|
+ paths = omitDuplicateAreaPathFromPaths(paths);
|
|
|
|
|
+
|
|
|
|
|
+ const regexps = paths.map(path => new RegExp(`^${escapeStringRegexp(path)}`));
|
|
|
|
|
+
|
|
|
|
|
+ // TODO: insertMany PageOperationBlock
|
|
|
|
|
|
|
|
// migrate recursively
|
|
// migrate recursively
|
|
|
try {
|
|
try {
|
|
@@ -1844,7 +1915,7 @@ class PageService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// TODO: use socket to send status to the client
|
|
// TODO: use socket to send status to the client
|
|
|
- async v5InitialMigration(grant) {
|
|
|
|
|
|
|
+ async normalizeAllPublicPages() {
|
|
|
// const socket = this.crowi.socketIoService.getAdminSocket();
|
|
// const socket = this.crowi.socketIoService.getAdminSocket();
|
|
|
|
|
|
|
|
let isUnique;
|
|
let isUnique;
|
|
@@ -1870,7 +1941,8 @@ class PageService {
|
|
|
|
|
|
|
|
// then migrate
|
|
// then migrate
|
|
|
try {
|
|
try {
|
|
|
- await this.normalizeParentRecursively(grant, null, true);
|
|
|
|
|
|
|
+ const Page = mongoose.model('Page') as unknown as PageModel;
|
|
|
|
|
+ await this.normalizeParentRecursively(Page.GRANT_PUBLIC, null, true);
|
|
|
}
|
|
}
|
|
|
catch (err) {
|
|
catch (err) {
|
|
|
logger.error('V5 initial miration failed.', err);
|
|
logger.error('V5 initial miration failed.', err);
|
|
@@ -1892,27 +1964,6 @@ class PageService {
|
|
|
await this._setIsV5CompatibleTrue();
|
|
await this._setIsV5CompatibleTrue();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /*
|
|
|
|
|
- * returns an array of js RegExp instance instead of RE2 instance for mongo filter
|
|
|
|
|
- */
|
|
|
|
|
- private async _generateRegExpsByPageIds(pageIds) {
|
|
|
|
|
- const Page = mongoose.model('Page') as unknown as PageModel;
|
|
|
|
|
-
|
|
|
|
|
- let result;
|
|
|
|
|
- try {
|
|
|
|
|
- result = await Page.findListByPageIds(pageIds, null, false);
|
|
|
|
|
- }
|
|
|
|
|
- catch (err) {
|
|
|
|
|
- logger.error('Failed to find pages by ids', err);
|
|
|
|
|
- throw err;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const { pages } = result;
|
|
|
|
|
- const regexps = pages.map(page => new RegExp(`^${escapeStringRegexp(page.path)}`));
|
|
|
|
|
-
|
|
|
|
|
- return regexps;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
private async _setIsV5CompatibleTrue() {
|
|
private async _setIsV5CompatibleTrue() {
|
|
|
try {
|
|
try {
|
|
|
await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
|
|
await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
|
|
@@ -2134,7 +2185,8 @@ class PageService {
|
|
|
objectMode: true,
|
|
objectMode: true,
|
|
|
async write(pageDocuments, encoding, callback) {
|
|
async write(pageDocuments, encoding, callback) {
|
|
|
for await (const document of pageDocuments) {
|
|
for await (const document of pageDocuments) {
|
|
|
- await Page.recountDescendantCountOfSelfAndDescendants(document._id);
|
|
|
|
|
|
|
+ const descendantCount = await Page.recountDescendantCount(document._id);
|
|
|
|
|
+ await Page.findByIdAndUpdate(document._id, { descendantCount });
|
|
|
}
|
|
}
|
|
|
callback();
|
|
callback();
|
|
|
},
|
|
},
|