page.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472
  1. import { GroupType, type HasObjectId, type IPage } from '@growi/core';
  2. import type {
  3. IPagePopulatedToShowRevision,
  4. IUserHasId,
  5. } from '@growi/core/dist/interfaces';
  6. import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
  7. import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
  8. import {
  9. addTrailingSlash,
  10. normalizePath,
  11. } from '@growi/core/dist/utils/path-utils';
  12. import assert from 'assert';
  13. import escapeStringRegexp from 'escape-string-regexp';
  14. import type mongoose from 'mongoose';
  15. import type {
  16. AnyObject,
  17. Document,
  18. HydratedDocument,
  19. Model,
  20. Types,
  21. } from 'mongoose';
  22. import { Schema } from 'mongoose';
  23. import mongoosePaginate from 'mongoose-paginate-v2';
  24. import uniqueValidator from 'mongoose-unique-validator';
  25. import nodePath from 'path';
  26. import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
  27. import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
  28. import type {
  29. IOptionsForCreate,
  30. IPagePathWithDescendantCount,
  31. } from '~/interfaces/page';
  32. import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
  33. import loggerFactory from '../../utils/logger';
  34. import type Crowi from '../crowi';
  35. import { collectAncestorPaths } from '../util/collect-ancestor-paths';
  36. import { getOrCreateModel } from '../util/mongoose-utils';
  37. import {
  38. extractToAncestorsPaths,
  39. getPageSchema,
  40. populateDataToShowRevision,
  41. } from './obsolete-page';
  42. import { USER_FIELDS_EXCEPT_CONFIDENTIAL } from './user/conts';
  43. import type { UserGroupDocument } from './user-group';
  44. import UserGroupRelation from './user-group-relation';
  45. type ObjectId = mongoose.Types.ObjectId;
  46. const logger = loggerFactory('growi:models:page');
  47. /*
  48. * define schema
  49. */
  50. const GRANT_PUBLIC = 1;
  51. const GRANT_RESTRICTED = 2;
  52. const GRANT_SPECIFIED = 3; // DEPRECATED
  53. const GRANT_OWNER = 4;
  54. const GRANT_USER_GROUP = 5;
  55. const PAGE_GRANT_ERROR = 1;
  56. const STATUS_PUBLISHED = 'published';
  57. const STATUS_DELETED = 'deleted';
  58. export interface PageDocument extends IPage, Document<Types.ObjectId> {
  59. [x: string]: any; // for obsolete methods
  60. getLatestRevisionBodyLength(): Promise<number | null | undefined>;
  61. calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>;
  62. populateDataToShowRevision(
  63. shouldExcludeBody?: boolean,
  64. ): Promise<IPagePopulatedToShowRevision & PageDocument>;
  65. }
  66. type TargetAndAncestorsResult = {
  67. targetAndAncestors: PageDocument[];
  68. rootPage: PageDocument;
  69. };
  70. type PaginatedPages = {
  71. pages: PageDocument[];
  72. totalCount: number;
  73. limit: number;
  74. offset: number;
  75. };
  76. export type FindRecentUpdatedPagesOption = {
  77. offset: number;
  78. limit: number;
  79. includeWipPage: boolean;
  80. includeTrashed: boolean;
  81. isRegExpEscapedFromPath: boolean;
  82. sort: 'updatedAt';
  83. desc: number;
  84. hideRestrictedByOwner: boolean;
  85. hideRestrictedByGroup: boolean;
  86. };
  87. export type CreateMethod = (
  88. path: string,
  89. body: string,
  90. user,
  91. options: IOptionsForCreate,
  92. ) => Promise<HydratedDocument<PageDocument>>;
  93. type FindByPathAndViewerMethod = (
  94. this: PageModel,
  95. id: string | ObjectId,
  96. user,
  97. userGroups?,
  98. includeEmpty?: boolean,
  99. ) => Promise<HydratedDocument<PageDocument> | null>;
  100. type CountByPathAndViewerMethod = (
  101. this: PageModel,
  102. id: string | ObjectId,
  103. user,
  104. userGroups?,
  105. includeEmpty?: boolean,
  106. ) => Promise<number>;
  107. export interface PageModel extends Model<PageDocument> {
  108. [x: string]: any; // for obsolete static methods
  109. createEmptyPage(
  110. path: string,
  111. parent,
  112. descendantCount?: number,
  113. ): Promise<HydratedDocument<PageDocument>>;
  114. findByIdAndViewer: FindByPathAndViewerMethod;
  115. countByIdAndViewer: CountByPathAndViewerMethod;
  116. findByIdsAndViewer(
  117. pageIds: ObjectIdLike[],
  118. user,
  119. userGroups?,
  120. includeEmpty?: boolean,
  121. includeAnyoneWithTheLink?: boolean,
  122. ): Promise<HydratedDocument<PageDocument>[]>;
  123. findByPath(
  124. path: string,
  125. includeEmpty?: boolean,
  126. ): Promise<HydratedDocument<PageDocument> | null>;
  127. findByPathAndViewer(
  128. path: string | null,
  129. user,
  130. userGroups?,
  131. useFindOne?: true,
  132. includeEmpty?: boolean,
  133. ): Promise<HydratedDocument<PageDocument> | null>;
  134. findByPathAndViewer(
  135. path: string | null,
  136. user,
  137. userGroups?,
  138. useFindOne?: false,
  139. includeEmpty?: boolean,
  140. ): Promise<HydratedDocument<PageDocument>[]>;
  141. descendantCountByPaths(
  142. paths: string[],
  143. user: IUserHasId,
  144. userGroups?,
  145. includeEmpty?: boolean,
  146. includeAnyoneWithTheLink?: boolean,
  147. ): Promise<IPagePathWithDescendantCount[]>;
  148. findParentByPath(
  149. path: string | null,
  150. ): Promise<HydratedDocument<PageDocument> | null>;
  151. findTargetAndAncestorsByPathOrId(
  152. pathOrId: string,
  153. ): Promise<TargetAndAncestorsResult>;
  154. findRecentUpdatedPages(
  155. path: string,
  156. user,
  157. option: FindRecentUpdatedPagesOption,
  158. includeEmpty?: boolean,
  159. ): Promise<PaginatedPages>;
  160. generateGrantCondition(
  161. user,
  162. userGroups: ObjectIdLike[] | null,
  163. includeAnyoneWithTheLink?: boolean,
  164. showPagesRestrictedByOwner?: boolean,
  165. showPagesRestrictedByGroup?: boolean,
  166. ): { $or: any[] };
  167. findNonEmptyClosestAncestor(
  168. path: string,
  169. ): Promise<HydratedDocument<PageDocument> | null>;
  170. findNotEmptyParentByPathRecursively(
  171. path: string,
  172. ): Promise<HydratedDocument<PageDocument> | null>;
  173. removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>;
  174. findTemplate(path: string): Promise<{
  175. templateBody?: string;
  176. templateTags?: string[];
  177. }>;
  178. removeGroupsToDeleteFromPages(
  179. pages: PageDocument[],
  180. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  181. ): Promise<void>;
  182. PageQueryBuilder: typeof PageQueryBuilder;
  183. GRANT_PUBLIC;
  184. GRANT_RESTRICTED;
  185. GRANT_SPECIFIED;
  186. GRANT_OWNER;
  187. GRANT_USER_GROUP;
  188. PAGE_GRANT_ERROR;
  189. STATUS_PUBLISHED;
  190. STATUS_DELETED;
  191. }
  192. const schema = new Schema<PageDocument, PageModel>(
  193. {
  194. parent: {
  195. type: Schema.Types.ObjectId,
  196. ref: 'Page',
  197. index: true,
  198. default: null,
  199. },
  200. descendantCount: { type: Number, default: 0 },
  201. isEmpty: { type: Boolean, default: false },
  202. path: {
  203. type: String,
  204. required: true,
  205. index: true,
  206. },
  207. revision: { type: Schema.Types.ObjectId, ref: 'Revision' },
  208. latestRevisionBodyLength: { type: Number },
  209. status: { type: String, default: STATUS_PUBLISHED, index: true },
  210. grant: { type: Number, default: GRANT_PUBLIC, index: true },
  211. grantedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  212. grantedGroups: {
  213. type: [
  214. {
  215. type: {
  216. type: String,
  217. enum: Object.values(GroupType),
  218. required: true,
  219. default: 'UserGroup',
  220. },
  221. item: {
  222. type: Schema.Types.ObjectId,
  223. refPath: 'grantedGroups.type',
  224. required: true,
  225. index: true,
  226. },
  227. },
  228. ],
  229. validate: [
  230. (arr) => {
  231. if (arr == null) return true;
  232. const uniqueItemValues = new Set(arr.map((e) => e.item));
  233. return arr.length === uniqueItemValues.size;
  234. },
  235. 'grantedGroups contains non unique item',
  236. ],
  237. default: [],
  238. required: true,
  239. },
  240. creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
  241. lastUpdateUser: { type: Schema.Types.ObjectId, ref: 'User' },
  242. liker: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  243. seenUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }],
  244. commentCount: { type: Number, default: 0 },
  245. expandContentWidth: { type: Boolean },
  246. wip: { type: Boolean },
  247. ttlTimestamp: { type: Date },
  248. updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
  249. deleteUser: { type: Schema.Types.ObjectId, ref: 'User' },
  250. deletedAt: { type: Date },
  251. },
  252. {
  253. timestamps: { createdAt: true, updatedAt: false },
  254. toJSON: { getters: true },
  255. toObject: { getters: true },
  256. },
  257. );
  258. // indexes
  259. schema.index({ createdAt: 1 });
  260. schema.index({ updatedAt: 1 });
  261. // apply plugins
  262. schema.plugin(mongoosePaginate);
  263. schema.plugin(uniqueValidator);
  264. export class PageQueryBuilder {
  265. query: any;
  266. constructor(query, includeEmpty = false) {
  267. this.query = query;
  268. if (!includeEmpty) {
  269. this.query = this.query.and({
  270. $or: [
  271. { isEmpty: false },
  272. { isEmpty: null }, // for v4 compatibility
  273. ],
  274. });
  275. }
  276. }
  277. /**
  278. * Used for filtering the pages at specified paths not to include unintentional pages.
  279. * @param pathsToFilter The paths to have additional filters as to be applicable
  280. * @returns PageQueryBuilder
  281. */
  282. addConditionToFilterByApplicableAncestors(
  283. pathsToFilter: string[],
  284. ): PageQueryBuilder {
  285. this.query = this.query.and({
  286. $or: [
  287. { path: '/' },
  288. {
  289. path: { $in: pathsToFilter },
  290. grant: GRANT_PUBLIC,
  291. status: STATUS_PUBLISHED,
  292. },
  293. {
  294. path: { $in: pathsToFilter },
  295. parent: { $ne: null },
  296. status: STATUS_PUBLISHED,
  297. },
  298. { path: { $nin: pathsToFilter }, status: STATUS_PUBLISHED },
  299. ],
  300. });
  301. return this;
  302. }
  303. addConditionToExcludeTrashed(): PageQueryBuilder {
  304. this.query = this.query.and({
  305. $or: [{ status: null }, { status: STATUS_PUBLISHED }],
  306. });
  307. return this;
  308. }
  309. addConditionToExcludeWipPage(): PageQueryBuilder {
  310. this.query = this.query.and({
  311. $or: [{ wip: undefined }, { wip: false }],
  312. });
  313. return this;
  314. }
  315. /**
  316. * generate the query to find the pages '{path}/*' and '{path}' self.
  317. * If top page, return without doing anything.
  318. */
  319. addConditionToListWithDescendants(path: string, option?): PageQueryBuilder {
  320. // No request is set for the top page
  321. if (isTopPage(path)) {
  322. return this;
  323. }
  324. const pathNormalized = normalizePath(path);
  325. const pathWithTrailingSlash = addTrailingSlash(path);
  326. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  327. this.query = this.query.and({
  328. $or: [
  329. { path: pathNormalized },
  330. { path: new RegExp(`^${startsPattern}`) },
  331. ],
  332. });
  333. return this;
  334. }
  335. /**
  336. * generate the query to find the pages '{path}/*' (exclude '{path}' self).
  337. */
  338. addConditionToListOnlyDescendants(path: string): PageQueryBuilder {
  339. // exclude the target page
  340. this.query = this.query.and({ path: { $ne: path } });
  341. if (isTopPage(path)) {
  342. return this;
  343. }
  344. const pathWithTrailingSlash = addTrailingSlash(path);
  345. const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
  346. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  347. return this;
  348. }
  349. addConditionToListOnlyAncestors(path: string): PageQueryBuilder {
  350. const pathNormalized = normalizePath(path);
  351. const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
  352. this.query = this.query
  353. // exclude the target page
  354. .and({ path: { $ne: path } })
  355. .and({ path: { $in: ancestorsPaths } });
  356. return this;
  357. }
  358. /**
  359. * generate the query to find pages that start with `path`
  360. *
  361. * In normal case, returns '{path}/*' and '{path}' self.
  362. * If top page, return without doing anything.
  363. *
  364. * *option*
  365. * Left for backward compatibility
  366. */
  367. addConditionToListByStartWith(str: string): PageQueryBuilder {
  368. const path = normalizePath(str);
  369. // No request is set for the top page
  370. if (isTopPage(path)) {
  371. return this;
  372. }
  373. const startsPattern = escapeStringRegexp(path);
  374. this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
  375. return this;
  376. }
  377. addConditionToListByNotStartWith(str: string): PageQueryBuilder {
  378. const path = normalizePath(str);
  379. // No request is set for the top page
  380. if (isTopPage(path)) {
  381. return this;
  382. }
  383. const startsPattern = escapeStringRegexp(str);
  384. this.query = this.query.and({
  385. path: new RegExp(`^(?!${startsPattern}).*$`),
  386. });
  387. return this;
  388. }
  389. addConditionToListByMatch(str: string): PageQueryBuilder {
  390. // No request is set for "/"
  391. if (str === '/') {
  392. return this;
  393. }
  394. const match = escapeStringRegexp(str);
  395. this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
  396. return this;
  397. }
  398. addConditionToListByNotMatch(str: string): PageQueryBuilder {
  399. // No request is set for "/"
  400. if (str === '/') {
  401. return this;
  402. }
  403. const match = escapeStringRegexp(str);
  404. this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
  405. return this;
  406. }
  407. async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
  408. // determine UserGroup condition
  409. const userGroups =
  410. user != null
  411. ? [
  412. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  413. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  414. user,
  415. )),
  416. ]
  417. : null;
  418. const grantConditions: any[] = [{ grant: null }, { grant: GRANT_PUBLIC }];
  419. if (user != null) {
  420. grantConditions.push({ grant: GRANT_OWNER, grantedUsers: user._id });
  421. }
  422. if (userGroups != null && userGroups.length > 0) {
  423. grantConditions.push({
  424. grant: GRANT_USER_GROUP,
  425. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  426. });
  427. }
  428. this.query = this.query.and({
  429. $or: grantConditions,
  430. });
  431. return this;
  432. }
  433. async addConditionAsMigratablePages(user): Promise<PageQueryBuilder> {
  434. this.query = this.query.and({
  435. $or: [
  436. { grant: { $ne: GRANT_RESTRICTED } },
  437. { grant: { $ne: GRANT_SPECIFIED } },
  438. ],
  439. });
  440. this.addConditionAsRootOrNotOnTree();
  441. this.addConditionAsNonRootPage();
  442. this.addConditionToExcludeTrashed();
  443. await this.addConditionForParentNormalization(user);
  444. return this;
  445. }
  446. // add viewer condition to PageQueryBuilder instance
  447. async addViewerCondition(
  448. user,
  449. userGroups = null,
  450. includeAnyoneWithTheLink = false,
  451. showPagesRestrictedByOwner = false,
  452. showPagesRestrictedByGroup = false,
  453. ): Promise<PageQueryBuilder> {
  454. const relatedUserGroups =
  455. user != null && userGroups == null
  456. ? [
  457. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  458. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  459. user,
  460. )),
  461. ]
  462. : userGroups;
  463. this.addConditionToFilteringByViewer(
  464. user,
  465. relatedUserGroups,
  466. includeAnyoneWithTheLink,
  467. showPagesRestrictedByOwner,
  468. showPagesRestrictedByGroup,
  469. );
  470. return this;
  471. }
  472. addConditionToFilteringByViewer(
  473. user,
  474. userGroups: ObjectIdLike[] | null,
  475. includeAnyoneWithTheLink = false,
  476. showPagesRestrictedByOwner = false,
  477. showPagesRestrictedByGroup = false,
  478. ): PageQueryBuilder {
  479. const condition = generateGrantCondition(
  480. user,
  481. userGroups,
  482. includeAnyoneWithTheLink,
  483. showPagesRestrictedByOwner,
  484. showPagesRestrictedByGroup,
  485. );
  486. this.query = this.query.and(condition);
  487. return this;
  488. }
  489. addConditionForSystemDeletion(): PageQueryBuilder {
  490. const condition = generateGrantConditionForSystemDeletion();
  491. this.query = this.query.and(condition);
  492. return this;
  493. }
  494. addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
  495. this.query = this.query.sort(sortOpt).skip(offset).limit(limit);
  496. return this;
  497. }
  498. addConditionAsNonRootPage(): PageQueryBuilder {
  499. this.query = this.query.and({ path: { $ne: '/' } });
  500. return this;
  501. }
  502. addConditionAsRootOrNotOnTree(): PageQueryBuilder {
  503. this.query = this.query.and({ parent: null });
  504. return this;
  505. }
  506. addConditionAsOnTree(): PageQueryBuilder {
  507. this.query = this.query.and({
  508. $or: [{ parent: { $ne: null } }, { path: '/' }],
  509. });
  510. return this;
  511. }
  512. /*
  513. * Add this condition when get any ancestor pages including the target's parent
  514. */
  515. addConditionToSortPagesByDescPath(): PageQueryBuilder {
  516. this.query = this.query.sort('-path');
  517. return this;
  518. }
  519. addConditionToSortPagesByAscPath(): PageQueryBuilder {
  520. this.query = this.query.sort('path');
  521. return this;
  522. }
  523. addConditionToMinimizeDataForRendering(): PageQueryBuilder {
  524. this.query = this.query.select(
  525. '_id path isEmpty grant revision descendantCount',
  526. );
  527. return this;
  528. }
  529. addConditionToListByPathsArray(paths): PageQueryBuilder {
  530. this.query = this.query.and({
  531. path: {
  532. $in: paths,
  533. },
  534. });
  535. return this;
  536. }
  537. addConditionToListByPageIdsArray(pageIds): PageQueryBuilder {
  538. this.query = this.query.and({
  539. _id: {
  540. $in: pageIds,
  541. },
  542. });
  543. return this;
  544. }
  545. addConditionToExcludeByPageIdsArray(pageIds): PageQueryBuilder {
  546. this.query = this.query.and({
  547. _id: {
  548. $nin: pageIds,
  549. },
  550. });
  551. return this;
  552. }
  553. populateDataToList(userPublicFields): PageQueryBuilder {
  554. // biome-ignore lint/plugin: populating is the purpose of this method
  555. this.query = this.query.populate({
  556. path: 'lastUpdateUser',
  557. select: userPublicFields,
  558. });
  559. return this;
  560. }
  561. populateDataToShowRevision(userPublicFields): PageQueryBuilder {
  562. this.query = populateDataToShowRevision(this.query, userPublicFields);
  563. return this;
  564. }
  565. addConditionToFilteringByParentId(parentId): PageQueryBuilder {
  566. this.query = this.query.and({ parent: parentId });
  567. return this;
  568. }
  569. }
  570. schema.statics.createEmptyPage = async function (
  571. path: string,
  572. parent: any,
  573. descendantCount = 0,
  574. ): Promise<HydratedDocument<PageDocument>> {
  575. if (parent == null) {
  576. throw Error('parent must not be null');
  577. }
  578. const page = new this();
  579. page.path = path;
  580. page.isEmpty = true;
  581. page.parent = parent;
  582. page.descendantCount = descendantCount;
  583. return page.save();
  584. };
  585. const findByIdAndViewer: FindByPathAndViewerMethod = async function (
  586. this,
  587. id,
  588. user,
  589. userGroups = null,
  590. includeEmpty = false,
  591. ) {
  592. const baseQuery = this.findOne({ _id: id });
  593. const relatedUserGroups =
  594. user != null && userGroups == null
  595. ? [
  596. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  597. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  598. user,
  599. )),
  600. ]
  601. : userGroups;
  602. const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
  603. queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
  604. return queryBuilder.query.exec();
  605. };
  606. schema.statics.findByIdAndViewer = findByIdAndViewer;
  607. const countByIdAndViewer: CountByPathAndViewerMethod = async function (
  608. this,
  609. id,
  610. user,
  611. userGroups = null,
  612. includeEmpty = false,
  613. ) {
  614. const baseQuery = this.countDocuments({ _id: id });
  615. const relatedUserGroups =
  616. user != null && userGroups == null
  617. ? [
  618. ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
  619. ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
  620. user,
  621. )),
  622. ]
  623. : userGroups;
  624. const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
  625. queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
  626. return queryBuilder.query.exec();
  627. };
  628. schema.statics.countByIdAndViewer = countByIdAndViewer;
  629. /**
  630. * Replace an existing page with an empty page.
  631. * It updates the children's parent to the new empty page's _id.
  632. * @param exPage a page document to be replaced
  633. * @returns Promise<void>
  634. */
  635. schema.statics.replaceTargetWithPage = async function (
  636. exPage,
  637. pageToReplaceWith?,
  638. deleteExPageIfEmpty = false,
  639. ) {
  640. // find parent
  641. const parent = await this.findOne({ _id: exPage.parent });
  642. if (parent == null) {
  643. throw Error('parent to update does not exist. Prepare parent first.');
  644. }
  645. // create empty page at path
  646. const newTarget =
  647. pageToReplaceWith == null
  648. ? await this.createEmptyPage(exPage.path, parent, exPage.descendantCount)
  649. : pageToReplaceWith;
  650. // find children by ex-page _id
  651. const children = await this.find({ parent: exPage._id });
  652. // bulkWrite
  653. const operationForNewTarget = {
  654. updateOne: {
  655. filter: { _id: newTarget._id },
  656. update: {
  657. parent: parent._id,
  658. },
  659. },
  660. };
  661. const operationsForChildren = {
  662. updateMany: {
  663. filter: {
  664. _id: { $in: children.map((d) => d._id) },
  665. },
  666. update: {
  667. parent: newTarget._id,
  668. },
  669. },
  670. };
  671. await this.bulkWrite([operationForNewTarget, operationsForChildren]);
  672. const isExPageEmpty = exPage.isEmpty;
  673. if (deleteExPageIfEmpty && isExPageEmpty) {
  674. await this.deleteOne({ _id: exPage._id });
  675. logger.warn('Deleted empty page since it was replaced with another page.');
  676. }
  677. return this.findById(newTarget._id);
  678. };
  679. /*
  680. * Find pages by ID and viewer.
  681. */
  682. schema.statics.findByIdsAndViewer = async function (
  683. pageIds: string[],
  684. user,
  685. userGroups?,
  686. includeEmpty?: boolean,
  687. includeAnyoneWithTheLink?: boolean,
  688. ): Promise<PageDocument[]> {
  689. const baseQuery = this.find({ _id: { $in: pageIds } });
  690. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  691. await queryBuilder.addViewerCondition(
  692. user,
  693. userGroups,
  694. includeAnyoneWithTheLink,
  695. );
  696. return queryBuilder.query.exec();
  697. };
  698. /*
  699. * Find a page by path and viewer. Pass true to useFindOne to use findOne method.
  700. */
  701. schema.statics.findByPathAndViewer = async function (
  702. path: string | null,
  703. user,
  704. userGroups = null,
  705. useFindOne = false,
  706. includeEmpty = false,
  707. ): Promise<((PageDocument | PageDocument[]) & HasObjectId) | null> {
  708. if (path == null) {
  709. throw new Error('path is required.');
  710. }
  711. const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
  712. const includeAnyoneWithTheLink = useFindOne;
  713. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  714. await queryBuilder.addViewerCondition(
  715. user,
  716. userGroups,
  717. includeAnyoneWithTheLink,
  718. );
  719. return queryBuilder.query.exec();
  720. };
  721. schema.statics.descendantCountByPaths = async function (
  722. paths: string[],
  723. user: IUserHasId,
  724. userGroups = null,
  725. includeEmpty = false,
  726. includeAnyoneWithTheLink = false,
  727. ): Promise<IPagePathWithDescendantCount[]> {
  728. if (paths.length === 0) {
  729. throw new Error('paths are required');
  730. }
  731. const baseQuery = this.find({ path: { $in: paths } });
  732. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  733. await queryBuilder.addViewerCondition(
  734. user,
  735. userGroups,
  736. includeAnyoneWithTheLink,
  737. );
  738. const conditions = queryBuilder.query._conditions;
  739. const aggregationPipeline = [
  740. {
  741. $match: conditions,
  742. },
  743. {
  744. $project: {
  745. _id: 0,
  746. path: 1,
  747. descendantCount: 1,
  748. },
  749. },
  750. {
  751. $group: {
  752. _id: '$path',
  753. descendantCount: { $first: '$descendantCount' },
  754. },
  755. },
  756. {
  757. $project: {
  758. _id: 0,
  759. path: '$_id',
  760. descendantCount: 1,
  761. },
  762. },
  763. ];
  764. const pages =
  765. await this.aggregate<IPagePathWithDescendantCount>(aggregationPipeline);
  766. return pages;
  767. };
  768. schema.statics.countByPathAndViewer = async function (
  769. path: string | null,
  770. user,
  771. userGroups = null,
  772. includeEmpty = false,
  773. ): Promise<number> {
  774. if (path == null) {
  775. throw new Error('path is required.');
  776. }
  777. const baseQuery = this.count({ path });
  778. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  779. await queryBuilder.addViewerCondition(user, userGroups);
  780. return queryBuilder.query.exec();
  781. };
  782. schema.statics.findRecentUpdatedPages = async function (
  783. path: string,
  784. user,
  785. options: FindRecentUpdatedPagesOption,
  786. includeEmpty = false,
  787. ): Promise<PaginatedPages> {
  788. const sortOpt = {};
  789. sortOpt[options.sort] = options.desc;
  790. if (path == null) {
  791. throw new Error('path is required.');
  792. }
  793. const baseQuery = this.find({});
  794. const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
  795. if (!options.includeTrashed) {
  796. queryBuilder.addConditionToExcludeTrashed();
  797. }
  798. if (!options.includeWipPage) {
  799. queryBuilder.addConditionToExcludeWipPage();
  800. }
  801. queryBuilder.addConditionToListWithDescendants(path, options);
  802. queryBuilder.populateDataToList(USER_FIELDS_EXCEPT_CONFIDENTIAL);
  803. await queryBuilder.addViewerCondition(
  804. user,
  805. undefined,
  806. undefined,
  807. !options.hideRestrictedByOwner,
  808. !options.hideRestrictedByGroup,
  809. );
  810. const pages = await this.paginate(queryBuilder.query.clone(), {
  811. lean: true,
  812. sort: sortOpt,
  813. offset: options.offset,
  814. limit: options.limit,
  815. });
  816. const results = {
  817. pages: pages.docs,
  818. totalCount: pages.totalDocs,
  819. offset: options.offset,
  820. limit: options.limit,
  821. };
  822. return results;
  823. };
  824. /*
  825. * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  826. * The result will include the target as well
  827. */
  828. schema.statics.findTargetAndAncestorsByPathOrId = async function (
  829. pathOrId: string,
  830. user,
  831. userGroups,
  832. ): Promise<TargetAndAncestorsResult> {
  833. let path: string;
  834. if (!hasSlash(pathOrId)) {
  835. const _id = pathOrId;
  836. const page = await this.findOne({ _id });
  837. path = page == null ? '/' : page.path;
  838. } else {
  839. path = pathOrId;
  840. }
  841. const ancestorPaths = collectAncestorPaths(path);
  842. ancestorPaths.push(path); // include target
  843. // Do not populate
  844. const queryBuilder = new PageQueryBuilder(this.find(), true);
  845. await queryBuilder.addViewerCondition(user, userGroups);
  846. const _targetAndAncestors: PageDocument[] = await queryBuilder
  847. .addConditionAsOnTree()
  848. .addConditionToListByPathsArray(ancestorPaths)
  849. .addConditionToMinimizeDataForRendering()
  850. .addConditionToSortPagesByDescPath()
  851. .query.lean()
  852. .exec();
  853. // no same path pages
  854. const ancestorsMap = new Map<string, PageDocument>();
  855. _targetAndAncestors.forEach((page) => {
  856. ancestorsMap.set(page.path, page);
  857. });
  858. const targetAndAncestors = Array.from(ancestorsMap.values());
  859. const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
  860. return { targetAndAncestors, rootPage };
  861. };
  862. /**
  863. * Create empty pages at paths at which no pages exist
  864. * @param paths Page paths
  865. * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
  866. */
  867. schema.statics.createEmptyPagesByPaths = async function (
  868. paths: string[],
  869. aggrPipelineForExistingPages: any[],
  870. ): Promise<void> {
  871. const existingPages = await this.aggregate(aggrPipelineForExistingPages);
  872. const existingPagePaths = existingPages.map((page) => page.path);
  873. const notExistingPagePaths = paths.filter(
  874. (path) => !existingPagePaths.includes(path),
  875. );
  876. await this.insertMany(
  877. notExistingPagePaths.map((path) => ({ path, isEmpty: true })),
  878. );
  879. };
  880. /**
  881. * Find a parent page by path
  882. */
  883. schema.statics.findParentByPath = async function (
  884. path: string,
  885. ): Promise<HydratedDocument<PageDocument> | null> {
  886. const parentPath = nodePath.dirname(path);
  887. const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
  888. const pagesCanBeParent = await builder.addConditionAsOnTree().query.exec();
  889. if (pagesCanBeParent.length >= 1) {
  890. return pagesCanBeParent[0]; // the earliest page will be the result
  891. }
  892. return null;
  893. };
  894. /*
  895. * Utils from obsolete-page.js
  896. */
  897. export async function pushRevision(pageData, newRevision, user) {
  898. await newRevision.save();
  899. pageData.revision = newRevision;
  900. pageData.latestRevisionBodyLength = newRevision.body.length;
  901. pageData.lastUpdateUser = user?._id ?? user;
  902. pageData.updatedAt = Date.now();
  903. return pageData.save();
  904. }
  905. /**
  906. * add/subtract descendantCount of pages with provided paths by increment.
  907. * increment can be negative number
  908. */
  909. schema.statics.incrementDescendantCountOfPageIds = async function (
  910. pageIds: ObjectIdLike[],
  911. increment: number,
  912. ): Promise<void> {
  913. await this.updateMany(
  914. { _id: { $in: pageIds } },
  915. { $inc: { descendantCount: increment } },
  916. );
  917. };
  918. /**
  919. * recount descendantCount of a page with the provided id and return it
  920. */
  921. schema.statics.recountDescendantCount = async function (
  922. id: ObjectIdLike,
  923. ): Promise<number> {
  924. const res = await this.aggregate([
  925. {
  926. $match: {
  927. parent: id,
  928. },
  929. },
  930. {
  931. $project: {
  932. parent: 1,
  933. isEmpty: 1,
  934. descendantCount: 1,
  935. },
  936. },
  937. {
  938. $group: {
  939. _id: '$parent',
  940. sumOfDescendantCount: {
  941. $sum: '$descendantCount',
  942. },
  943. sumOfDocsCount: {
  944. $sum: {
  945. // biome-ignore lint/suspicious/noThenProperty: ignore
  946. $cond: { if: { $eq: ['$isEmpty', true] }, then: 0, else: 1 }, // exclude isEmpty true page from sumOfDocsCount
  947. },
  948. },
  949. },
  950. },
  951. {
  952. $set: {
  953. descendantCount: {
  954. $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
  955. },
  956. },
  957. },
  958. ]);
  959. return res.length === 0 ? 0 : res[0].descendantCount;
  960. };
  961. schema.statics.findAncestorsUsingParentRecursively = async function (
  962. pageId: ObjectIdLike,
  963. shouldIncludeTarget: boolean,
  964. ) {
  965. const self = this;
  966. const target = await this.findById(pageId);
  967. if (target == null) {
  968. throw Error('Target not found');
  969. }
  970. async function findAncestorsRecursively(
  971. target,
  972. ancestors = shouldIncludeTarget ? [target] : [],
  973. ) {
  974. const parent = await self.findOne({ _id: target.parent });
  975. if (parent == null) {
  976. return ancestors;
  977. }
  978. return findAncestorsRecursively(parent, [...ancestors, parent]);
  979. }
  980. return findAncestorsRecursively(target);
  981. };
  982. // TODO: write test code
  983. /**
  984. * Recursively removes empty pages at leaf position.
  985. * @param pageId ObjectIdLike
  986. * @returns Promise<void>
  987. */
  988. schema.statics.removeLeafEmptyPagesRecursively = async function (
  989. pageId: ObjectIdLike,
  990. ): Promise<void> {
  991. const self = this;
  992. const initialPage = await this.findById(pageId);
  993. if (initialPage == null) {
  994. return;
  995. }
  996. if (!initialPage.isEmpty) {
  997. return;
  998. }
  999. async function generatePageIdsToRemove(
  1000. childPage,
  1001. page,
  1002. pageIds: ObjectIdLike[] = [],
  1003. ): Promise<ObjectIdLike[]> {
  1004. if (!page.isEmpty) {
  1005. return pageIds;
  1006. }
  1007. const isChildrenOtherThanTargetExist = await self.exists({
  1008. _id: { $ne: childPage?._id },
  1009. parent: page._id,
  1010. });
  1011. if (isChildrenOtherThanTargetExist) {
  1012. return pageIds;
  1013. }
  1014. pageIds.push(page._id);
  1015. const nextPage = await self.findById(page.parent);
  1016. if (nextPage == null) {
  1017. return pageIds;
  1018. }
  1019. return generatePageIdsToRemove(page, nextPage, pageIds);
  1020. }
  1021. const pageIdsToRemove = await generatePageIdsToRemove(null, initialPage);
  1022. await this.deleteMany({ _id: { $in: pageIdsToRemove } });
  1023. };
  1024. schema.statics.normalizeDescendantCountById = async function (pageId) {
  1025. const children = await this.find({ parent: pageId });
  1026. const sumChildrenDescendantCount = children
  1027. .map((d) => d.descendantCount)
  1028. .reduce((c1, c2) => c1 + c2);
  1029. const sumChildPages = children.filter((p) => !p.isEmpty).length;
  1030. return this.updateOne(
  1031. { _id: pageId },
  1032. { $set: { descendantCount: sumChildrenDescendantCount + sumChildPages } },
  1033. { new: true },
  1034. );
  1035. };
  1036. schema.statics.takeOffFromTree = async function (pageId: ObjectIdLike) {
  1037. return this.findByIdAndUpdate(pageId, { $set: { parent: null } });
  1038. };
  1039. schema.statics.removeEmptyPages = async function (
  1040. pageIdsToNotRemove: ObjectIdLike[],
  1041. paths: string[],
  1042. ): Promise<void> {
  1043. await this.deleteMany({
  1044. _id: {
  1045. $nin: pageIdsToNotRemove,
  1046. },
  1047. path: {
  1048. $in: paths,
  1049. },
  1050. isEmpty: true,
  1051. });
  1052. };
  1053. /**
  1054. * Find a not empty parent recursively.
  1055. * @param {string} path
  1056. * @returns {Promise<PageDocument | null>}
  1057. */
  1058. schema.statics.findNotEmptyParentByPathRecursively = async function (
  1059. path: string,
  1060. ): Promise<PageDocument | null> {
  1061. const parent = await this.findParentByPath(path);
  1062. if (parent == null) {
  1063. return null;
  1064. }
  1065. const recursive = async (page: PageDocument): Promise<PageDocument> => {
  1066. if (!page.isEmpty) {
  1067. return page;
  1068. }
  1069. const next = await this.findById(page.parent);
  1070. if (next == null || isTopPage(next.path)) {
  1071. return page;
  1072. }
  1073. return recursive(next);
  1074. };
  1075. const notEmptyParent = await recursive(parent);
  1076. return notEmptyParent;
  1077. };
  1078. schema.statics.findParent = async function (
  1079. pageId,
  1080. ): Promise<PageDocument | null> {
  1081. return this.findOne({ _id: pageId });
  1082. };
  1083. schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
  1084. export function generateGrantCondition(
  1085. user,
  1086. userGroups: ObjectIdLike[] | null,
  1087. includeAnyoneWithTheLink = false,
  1088. showPagesRestrictedByOwner = false,
  1089. showPagesRestrictedByGroup = false,
  1090. ): { $or: any[] } {
  1091. const grantConditions: AnyObject[] = [
  1092. { grant: null },
  1093. { grant: GRANT_PUBLIC },
  1094. ];
  1095. if (includeAnyoneWithTheLink) {
  1096. grantConditions.push({ grant: GRANT_RESTRICTED });
  1097. }
  1098. if (showPagesRestrictedByOwner) {
  1099. grantConditions.push({ grant: GRANT_SPECIFIED }, { grant: GRANT_OWNER });
  1100. } else if (user != null) {
  1101. grantConditions.push(
  1102. { grant: GRANT_SPECIFIED, grantedUsers: user._id },
  1103. { grant: GRANT_OWNER, grantedUsers: user._id },
  1104. );
  1105. }
  1106. if (showPagesRestrictedByGroup) {
  1107. grantConditions.push({ grant: GRANT_USER_GROUP });
  1108. } else if (userGroups != null && userGroups.length > 0) {
  1109. grantConditions.push({
  1110. grant: GRANT_USER_GROUP,
  1111. grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
  1112. });
  1113. }
  1114. return {
  1115. $or: grantConditions,
  1116. };
  1117. }
  1118. schema.statics.generateGrantCondition = generateGrantCondition;
  1119. function generateGrantConditionForSystemDeletion(): { $or: any[] } {
  1120. const grantCondition: AnyObject[] = [
  1121. { grant: null },
  1122. { grant: GRANT_PUBLIC },
  1123. { grant: GRANT_RESTRICTED },
  1124. { grant: GRANT_SPECIFIED },
  1125. { grant: GRANT_OWNER },
  1126. { grant: GRANT_USER_GROUP },
  1127. ];
  1128. return {
  1129. $or: grantCondition,
  1130. };
  1131. }
  1132. schema.statics.generateGrantConditionForSystemDeletion =
  1133. generateGrantConditionForSystemDeletion;
  1134. // find ancestor page with isEmpty: false. If parameter path is '/', return null
  1135. schema.statics.findNonEmptyClosestAncestor = async function (
  1136. path: string,
  1137. ): Promise<PageDocument | null> {
  1138. if (path === '/') {
  1139. return null;
  1140. }
  1141. const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
  1142. const ancestors = await builderForAncestors
  1143. .addConditionToListOnlyAncestors(path) // only ancestor paths
  1144. .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
  1145. .query.exec();
  1146. return ancestors[0] ?? null;
  1147. };
  1148. schema.statics.removeGroupsToDeleteFromPages = async function (
  1149. pages: PageDocument[],
  1150. groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
  1151. ) {
  1152. const groupsToDeleteIds = groupsToDelete.map((group) => group._id.toString());
  1153. const pageGroups = pages.reduce(
  1154. (
  1155. acc: { canPublicize: PageDocument[]; cannotPublicize: PageDocument[] },
  1156. page,
  1157. ) => {
  1158. const canPublicize = page.grantedGroups.every((group) =>
  1159. groupsToDeleteIds.includes(getIdForRef(group.item).toString()),
  1160. );
  1161. acc[canPublicize ? 'canPublicize' : 'cannotPublicize'].push(page);
  1162. return acc;
  1163. },
  1164. { canPublicize: [], cannotPublicize: [] },
  1165. );
  1166. // Only publicize pages that can only be accessed by the groups to be deleted
  1167. const publicizeQueries = pageGroups.canPublicize.map((page) => {
  1168. return {
  1169. updateOne: {
  1170. filter: { _id: page._id },
  1171. update: {
  1172. grantedGroups: [],
  1173. grant: this.GRANT_PUBLIC,
  1174. },
  1175. },
  1176. };
  1177. });
  1178. // Remove the groups to be deleted from the grantedGroups of the pages that can be accessed by other groups
  1179. const removeFromGrantedGroupsQueries = pageGroups.cannotPublicize.map(
  1180. (page) => {
  1181. return {
  1182. updateOne: {
  1183. filter: { _id: page._id },
  1184. update: {
  1185. $set: {
  1186. grantedGroups: page.grantedGroups.filter(
  1187. (group) =>
  1188. !groupsToDeleteIds.includes(
  1189. getIdForRef(group.item).toString(),
  1190. ),
  1191. ),
  1192. },
  1193. },
  1194. },
  1195. };
  1196. },
  1197. );
  1198. await this.bulkWrite([
  1199. ...publicizeQueries,
  1200. ...removeFromGrantedGroupsQueries,
  1201. ]);
  1202. };
  1203. /*
  1204. * get latest revision body length
  1205. */
  1206. schema.methods.getLatestRevisionBodyLength = async function (
  1207. this: PageDocument,
  1208. ): Promise<number | null | undefined> {
  1209. if (!this.isLatestRevision() || this.revision == null) {
  1210. return null;
  1211. }
  1212. if (this.latestRevisionBodyLength == null) {
  1213. await this.calculateAndUpdateLatestRevisionBodyLength();
  1214. }
  1215. return this.latestRevisionBodyLength;
  1216. };
  1217. /*
  1218. * calculate and update latestRevisionBodyLength
  1219. */
  1220. schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function (
  1221. this: PageDocument,
  1222. ): Promise<void> {
  1223. if (!this.isLatestRevision() || this.revision == null) {
  1224. logger.error('revision field is required.');
  1225. return;
  1226. }
  1227. // biome-ignore lint/plugin: allow populate for backward compatibility
  1228. const populatedPageDocument = await this.populate<PageDocument>(
  1229. 'revision',
  1230. 'body',
  1231. );
  1232. assert(populatedPageDocument.revision != null);
  1233. assert(isPopulated(populatedPageDocument.revision));
  1234. this.latestRevisionBodyLength = populatedPageDocument.revision.body.length;
  1235. await this.save();
  1236. };
  1237. schema.methods.publish = function () {
  1238. this.wip = undefined;
  1239. this.ttlTimestamp = undefined;
  1240. };
  1241. schema.methods.unpublish = function () {
  1242. this.wip = true;
  1243. this.ttlTimestamp = undefined;
  1244. };
  1245. schema.methods.makeWip = function (disableTtl: boolean) {
  1246. this.wip = true;
  1247. if (!disableTtl) {
  1248. this.ttlTimestamp = new Date();
  1249. }
  1250. };
  1251. /*
  1252. * Merge obsolete page model methods and define new methods which depend on crowi instance
  1253. */
  1254. export default function PageModel(crowi: Crowi | null): any {
  1255. // add old page schema methods
  1256. const pageSchema = getPageSchema(crowi);
  1257. schema.methods = { ...pageSchema.methods, ...schema.methods };
  1258. schema.statics = { ...pageSchema.statics, ...schema.statics };
  1259. return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
  1260. }