Просмотр исходного кода

Merge pull request #10639 from growilabs/imprv/176284-persist-activity-on-page-view

imprv: Persist activity on page view
Yuki Takei 3 месяцев назад
Родитель
Сommit
a843943edd

+ 25 - 10
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -2,10 +2,11 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type {
 import type {
   IDataWithRequiredMeta,
   IDataWithRequiredMeta,
   IPage,
   IPage,
+  IPageInfoBasic,
   IPageNotFoundInfo,
   IPageNotFoundInfo,
   IUser,
   IUser,
-} from '@growi/core/dist/interfaces';
-import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core/dist/interfaces';
+} from '@growi/core';
+import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core';
 import {
 import {
   isPermalink as _isPermalink,
   isPermalink as _isPermalink,
   isTopPage,
   isTopPage,
@@ -15,7 +16,7 @@ import assert from 'assert';
 import type { HydratedDocument, model } from 'mongoose';
 import type { HydratedDocument, model } from 'mongoose';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import type {
 import type {
   IPageRedirect,
   IPageRedirect,
   PageRedirectModel,
   PageRedirectModel,
@@ -252,11 +253,14 @@ export async function getPageDataForSameRoute(
   props: Pick<CommonEachProps, 'currentPathname'> &
   props: Pick<CommonEachProps, 'currentPathname'> &
     Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>;
     Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>;
   internalProps?: {
   internalProps?: {
-    pageId?: string;
+    pageWithMeta?:
+      | IDataWithRequiredMeta<PageDocument, IPageInfoBasic>
+      | IDataWithRequiredMeta<null, IPageNotFoundInfo>;
   };
   };
 }> {
 }> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
-  const { user } = req;
+  const { crowi, user } = req;
+  const { pageService, pageGrantService } = crowi;
 
 
   const pathname = decodeURIComponent(
   const pathname = decodeURIComponent(
     context.resolvedUrl?.split('?')[0] ?? '/',
     context.resolvedUrl?.split('?')[0] ?? '/',
@@ -278,13 +282,19 @@ export async function getPageDataForSameRoute(
   }
   }
 
 
   // For same route access, do minimal page lookup
   // For same route access, do minimal page lookup
-  const basicPageInfo = await Page.findOne(
-    isPermalink ? { _id: pageId } : { path: resolvedPagePath },
-  ).exec();
+  const pageWithMetaBasicOnly = await findPageAndMetaDataByViewer(
+    pageService,
+    pageGrantService,
+    pageId,
+    resolvedPagePath,
+    user,
+    false, // isSharedPage
+    true, // basicOnly
+  );
 
 
   const currentPathname = resolveFinalizedPathname(
   const currentPathname = resolveFinalizedPathname(
     resolvedPagePath,
     resolvedPagePath,
-    basicPageInfo,
+    pageWithMetaBasicOnly.data,
     isPermalink,
     isPermalink,
   );
   );
 
 
@@ -295,7 +305,12 @@ export async function getPageDataForSameRoute(
       redirectFrom,
       redirectFrom,
     },
     },
     internalProps: {
     internalProps: {
-      pageId: basicPageInfo?._id?.toString(),
+      pageWithMeta: pageWithMetaBasicOnly.data?.isEmpty
+        ? {
+            data: null,
+            meta: { isNotFound: true, isForbidden: false },
+          }
+        : pageWithMetaBasicOnly,
     },
     },
   };
   };
 }
 }

+ 73 - 7
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -1,6 +1,19 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+import type {
+  IDataWithMeta,
+  IDataWithRequiredMeta,
+  IPageInfoBasic,
+  IPageNotFoundInfo,
+} from '@growi/core';
+import { isIPageNotFoundInfo } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
 
 
+import {
+  SupportedAction,
+  type SupportedActionType,
+} from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { PageDocument } from '~/server/models/page';
 
 
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import {
 import {
@@ -8,11 +21,11 @@ import {
   getServerSideI18nProps,
   getServerSideI18nProps,
 } from '../common-props';
 } from '../common-props';
 import {
 import {
-  getActivityAction,
   getServerSideGeneralPageProps,
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
   getServerSideRendererConfigProps,
 } from '../general-page';
 } from '../general-page';
 import { isValidGeneralPageInitialProps } from '../general-page/type-guards';
 import { isValidGeneralPageInitialProps } from '../general-page/type-guards';
+import type { IPageToShowRevisionWithMeta } from '../general-page/types';
 import { addActivity } from '../utils/activity';
 import { addActivity } from '../utils/activity';
 import { mergeGetServerSidePropsResults } from '../utils/server-side-props';
 import { mergeGetServerSidePropsResults } from '../utils/server-side-props';
 import { NEXT_JS_ROUTING_PAGE } from './consts';
 import { NEXT_JS_ROUTING_PAGE } from './consts';
@@ -52,6 +65,43 @@ function emitPageSeenEvent(
   pageEvent.emit('seen', pageId, user);
   pageEvent.emit('seen', pageId, user);
 }
 }
 
 
+function getActivityAction(
+  isIdenticalPathPage: boolean,
+  pageWithMeta?:
+    | IPageToShowRevisionWithMeta
+    | IDataWithRequiredMeta<PageDocument, IPageInfoBasic>
+    | IDataWithMeta<null, IPageNotFoundInfo>
+    | null,
+): SupportedActionType {
+  if (isIdenticalPathPage) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+
+  const meta = pageWithMeta?.meta;
+  if (isIPageNotFoundInfo(meta)) {
+    if (meta.isForbidden) {
+      return SupportedAction.ACTION_PAGE_FORBIDDEN;
+    }
+
+    if (meta.isNotFound) {
+      return SupportedAction.ACTION_PAGE_NOT_FOUND;
+    }
+  }
+
+  const pagePath = pageWithMeta?.data?.path;
+  if (pagePath != null) {
+    if (pagePathUtils.isUsersHomepage(pagePath)) {
+      return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+    }
+
+    if (!pagePathUtils.isCreatablePage(pagePath)) {
+      return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+    }
+  }
+
+  return SupportedAction.ACTION_PAGE_VIEW;
+}
+
 export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -104,8 +154,15 @@ export async function getServerSidePropsForInitial(
   // Add user to seen users
   // Add user to seen users
   emitPageSeenEvent(context, mergedProps.pageWithMeta?.data?._id);
   emitPageSeenEvent(context, mergedProps.pageWithMeta?.data?._id);
 
 
-  // -- TODO: persist activity
-  // await addActivity(context, getActivityAction(mergedProps));
+  // Persist activity
+  addActivity(
+    context,
+    getActivityAction(
+      mergedProps.isIdenticalPathPage,
+      mergedProps.pageWithMeta,
+    ),
+  );
+
   return mergedResult;
   return mergedResult;
 }
 }
 
 
@@ -122,11 +179,20 @@ export async function getServerSidePropsForSameRoute(
   const { props: pageDataProps, internalProps } = pageDataForSameRouteResult;
   const { props: pageDataProps, internalProps } = pageDataForSameRouteResult;
 
 
   // Add user to seen users
   // Add user to seen users
-  emitPageSeenEvent(context, internalProps?.pageId);
+  emitPageSeenEvent(
+    context,
+    internalProps?.pageWithMeta?.data?._id?.toString(),
+  );
+
+  // Persist activity
+  addActivity(
+    context,
+    getActivityAction(
+      pageDataProps.isIdenticalPathPage,
+      internalProps?.pageWithMeta,
+    ),
+  );
 
 
-  // -- TODO: persist activity
-  // const mergedProps = await mergedResult.props;
-  // await addActivity(context, getActivityAction(mergedProps));
   const mergedResult = mergeGetServerSidePropsResults(
   const mergedResult = mergeGetServerSidePropsResults(
     { props: pageDataProps },
     { props: pageDataProps },
     i18nPropsResult,
     i18nPropsResult,

+ 0 - 37
apps/app/src/pages/general-page/get-activity-action.ts

@@ -1,37 +0,0 @@
-import type {
-  IDataWithMeta,
-  IPageNotFoundInfo,
-} from '@growi/core/dist/interfaces';
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import type { SupportedActionType } from '~/interfaces/activity';
-import { SupportedAction } from '~/interfaces/activity';
-
-import type { IPageToShowRevisionWithMeta } from './types';
-
-export const getActivityAction = (props: {
-  isNotCreatable: boolean;
-  isForbidden: boolean;
-  isNotFound: boolean;
-  pageWithMeta?:
-    | IPageToShowRevisionWithMeta
-    | IDataWithMeta<null, IPageNotFoundInfo>
-    | null;
-}): SupportedActionType => {
-  if (props.isNotCreatable) {
-    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
-  }
-  if (props.isForbidden) {
-    return SupportedAction.ACTION_PAGE_FORBIDDEN;
-  }
-  if (props.isNotFound) {
-    return SupportedAction.ACTION_PAGE_NOT_FOUND;
-  }
-
-  // Type-safe access to page data - only access path if data is not null
-  const pagePath = props.pageWithMeta?.data?.path ?? '';
-  if (pagePathUtils.isUsersHomepage(pagePath)) {
-    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
-  }
-  return SupportedAction.ACTION_PAGE_VIEW;
-};

+ 0 - 1
apps/app/src/pages/general-page/index.ts

@@ -2,7 +2,6 @@ export {
   getServerSideGeneralPageProps,
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
   getServerSideRendererConfigProps,
 } from './configuration-props';
 } from './configuration-props';
-export { getActivityAction } from './get-activity-action';
 export { isValidGeneralPageInitialProps } from './type-guards';
 export { isValidGeneralPageInitialProps } from './type-guards';
 export type * from './types';
 export type * from './types';
 export { useInitialCSRFetch } from './use-initial-skip-ssr-fetch';
 export { useInitialCSRFetch } from './use-initial-skip-ssr-fetch';

+ 24 - 2
apps/app/src/pages/share/[[...path]]/server-side-props.ts

@@ -1,11 +1,16 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
 
+import {
+  SupportedAction,
+  type SupportedActionType,
+} from '~/interfaces/activity';
+import type { IShareLinkHasId } from '~/interfaces/share-link';
+
 import {
 import {
   getServerSideCommonInitialProps,
   getServerSideCommonInitialProps,
   getServerSideI18nProps,
   getServerSideI18nProps,
 } from '../../common-props';
 } from '../../common-props';
 import {
 import {
-  getActivityAction,
   getServerSideGeneralPageProps,
   getServerSideGeneralPageProps,
   getServerSideRendererConfigProps,
   getServerSideRendererConfigProps,
   isValidGeneralPageInitialProps,
   isValidGeneralPageInitialProps,
@@ -23,6 +28,21 @@ const basisProps = {
   },
   },
 };
 };
 
 
+function getActivityAction(props: {
+  isExpired: boolean | undefined;
+  shareLink: IShareLinkHasId | undefined;
+}): SupportedActionType {
+  if (props.isExpired) {
+    return SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW;
+  }
+
+  if (props.shareLink == null) {
+    return SupportedAction.ACTION_SHARE_LINK_NOT_FOUND;
+  }
+
+  return SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW;
+}
+
 export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -67,6 +87,8 @@ export async function getServerSidePropsForInitial(
     throw new Error('Invalid merged props structure');
     throw new Error('Invalid merged props structure');
   }
   }
 
 
-  await addActivity(context, getActivityAction(mergedProps));
+  // Persist activity
+  addActivity(context, getActivityAction(mergedProps));
+
   return mergedResult;
   return mergedResult;
 }
 }

+ 43 - 1
apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts

@@ -1,6 +1,7 @@
 import type {
 import type {
   IDataWithRequiredMeta,
   IDataWithRequiredMeta,
   IPageInfo,
   IPageInfo,
+  IPageInfoBasic,
   IPageInfoExt,
   IPageInfoExt,
   IPageInfoForEmpty,
   IPageInfoForEmpty,
   IPageInfoForOperation,
   IPageInfoForOperation,
@@ -20,6 +21,35 @@ import type { IPageGrantService } from '~/server/service/page-grant';
 import Subscription from '../../models/subscription';
 import Subscription from '../../models/subscription';
 import type { IPageService } from './page-service';
 import type { IPageService } from './page-service';
 
 
+// Overload: basicOnly = true returns basic info only
+export async function findPageAndMetaDataByViewer(
+  pageService: IPageService,
+  pageGrantService: IPageGrantService,
+  pageId: string | null,
+  path: string | null,
+  user: HydratedDocument<IUser> | undefined,
+  isSharedPage: boolean,
+  basicOnly: true,
+): Promise<
+  | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoBasic>
+  | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+>;
+
+// Overload: basicOnly = false or undefined returns extended info
+export async function findPageAndMetaDataByViewer(
+  pageService: IPageService,
+  pageGrantService: IPageGrantService,
+  pageId: string | null,
+  path: string | null,
+  user?: HydratedDocument<IUser>,
+  isSharedPage?: boolean,
+  basicOnly?: false,
+): Promise<
+  | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+  | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+>;
+
+// Implementation
 export async function findPageAndMetaDataByViewer(
 export async function findPageAndMetaDataByViewer(
   pageService: IPageService,
   pageService: IPageService,
   pageGrantService: IPageGrantService,
   pageGrantService: IPageGrantService,
@@ -28,8 +58,12 @@ export async function findPageAndMetaDataByViewer(
   path: string | null, // either pageId or path must be specified
   path: string | null, // either pageId or path must be specified
   user?: HydratedDocument<IUser>,
   user?: HydratedDocument<IUser>,
   isSharedPage = false,
   isSharedPage = false,
+  basicOnly = false,
 ): Promise<
 ): Promise<
-  | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+  | IDataWithRequiredMeta<
+      HydratedDocument<PageDocument>,
+      IPageInfoExt | IPageInfoBasic
+    >
   | IDataWithRequiredMeta<null, IPageNotFoundInfo>
   | IDataWithRequiredMeta<null, IPageNotFoundInfo>
 > {
 > {
   assert(pageId != null || path != null);
   assert(pageId != null || path != null);
@@ -65,6 +99,14 @@ export async function findPageAndMetaDataByViewer(
   const isGuestUser = user == null;
   const isGuestUser = user == null;
   const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
   const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
 
+  // Return basic info only without additional DB queries and calculations
+  if (basicOnly) {
+    return {
+      data: page,
+      meta: basicPageInfo,
+    };
+  }
+
   if (isSharedPage) {
   if (isSharedPage) {
     return {
     return {
       data: page,
       data: page,

+ 7 - 29
apps/app/src/server/service/page/index.ts

@@ -6,23 +6,18 @@ import {
 } from '@growi/core';
 } from '@growi/core';
 import type {
 import type {
   HasObjectId,
   HasObjectId,
-  IDataWithRequiredMeta,
   IGrantedGroup,
   IGrantedGroup,
   IPage,
   IPage,
-  IPageInfo,
-  IPageInfoExt,
-  IPageInfoForEmpty,
-  IPageInfoForEntity,
-  IPageInfoForOperation,
-  IPageNotFoundInfo,
+  IPageInfoBasic,
+  IPageInfoBasicForEmpty,
+  IPageInfoBasicForEntity,
   IRevisionHasId,
   IRevisionHasId,
   IUser,
   IUser,
   IUserHasId,
   IUserHasId,
   Ref,
   Ref,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
-import { isIPageInfoForEntity, PageGrant } from '@growi/core/dist/interfaces';
+import { PageGrant } from '@growi/core/dist/interfaces';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
-import assert from 'assert';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 import type { Cursor, HydratedDocument } from 'mongoose';
 import type { Cursor, HydratedDocument } from 'mongoose';
@@ -36,7 +31,6 @@ import type { ExternalUserGroupDocument } from '~/features/external-user-group/s
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { isAiEnabled } from '~/features/openai/server/services';
 import { isAiEnabled } from '~/features/openai/server/services';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import type { BookmarkedPage } from '~/interfaces/bookmark-info';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
@@ -104,8 +98,6 @@ const {
   isUsersTopPage,
   isUsersTopPage,
   isMovablePage,
   isMovablePage,
   isUsersHomepage,
   isUsersHomepage,
-  hasSlash,
-  generateChildrenRegExp,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const { addTrailingSlash } = pathUtils;
 const { addTrailingSlash } = pathUtils;
@@ -3269,15 +3261,7 @@ class PageService implements IPageService {
   constructBasicPageInfo(
   constructBasicPageInfo(
     page: HydratedDocument<PageDocument>,
     page: HydratedDocument<PageDocument>,
     isGuestUser?: boolean,
     isGuestUser?: boolean,
-  ):
-    | Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >
-    | Omit<
-        IPageInfoForEntity,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      > {
+  ): IPageInfoBasic {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const pageId = page._id.toString();
     const pageId = page._id.toString();
 
 
@@ -3289,10 +3273,7 @@ class PageService implements IPageService {
         isEmpty: true,
         isEmpty: true,
         isMovable,
         isMovable,
         isRevertible: false,
         isRevertible: false,
-      } satisfies Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >;
+      } satisfies IPageInfoBasicForEmpty;
     }
     }
 
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
@@ -3314,10 +3295,7 @@ class PageService implements IPageService {
       // the page must have a revision if it is not empty
       // the page must have a revision if it is not empty
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       latestRevisionId: getIdStringForRef(page.revision!),
       latestRevisionId: getIdStringForRef(page.revision!),
-    } satisfies Omit<
-      IPageInfoForEntity,
-      'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-    >;
+    } satisfies IPageInfoBasicForEntity;
 
 
     return infoForEntity;
     return infoForEntity;
   }
   }

+ 2 - 14
apps/app/src/server/service/page/page-service.ts

@@ -1,13 +1,9 @@
 import type { EventEmitter } from 'node:events';
 import type { EventEmitter } from 'node:events';
 import type {
 import type {
   HasObjectId,
   HasObjectId,
-  IDataWithRequiredMeta,
   IGrantedGroup,
   IGrantedGroup,
   IPage,
   IPage,
-  IPageInfoExt,
-  IPageInfoForEmpty,
-  IPageInfoForEntity,
-  IPageNotFoundInfo,
+  IPageInfoBasic,
   IUser,
   IUser,
   IUserHasId,
   IUserHasId,
   PageGrant,
   PageGrant,
@@ -80,15 +76,7 @@ export interface IPageService {
   constructBasicPageInfo(
   constructBasicPageInfo(
     page: HydratedDocument<PageDocument>,
     page: HydratedDocument<PageDocument>,
     isGuestUser?: boolean,
     isGuestUser?: boolean,
-  ):
-    | Omit<
-        IPageInfoForEmpty,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >
-    | Omit<
-        IPageInfoForEntity,
-        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
-      >;
+  ): IPageInfoBasic;
   normalizeAllPublicPages(): Promise<void>;
   normalizeAllPublicPages(): Promise<void>;
   canDelete(
   canDelete(
     page: PageDocument,
     page: PageDocument,

+ 12 - 0
packages/core/src/interfaces/page.ts

@@ -170,6 +170,18 @@ export const isIPageInfoForEntity = (
   return isIPageInfo(pageInfo) && pageInfo.isEmpty === false;
   return isIPageInfo(pageInfo) && pageInfo.isEmpty === false;
 };
 };
 
 
+export type IPageInfoBasicForEmpty = Omit<
+  IPageInfoForEmpty,
+  'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely' | 'isBookmarked'
+>;
+
+export type IPageInfoBasicForEntity = Omit<
+  IPageInfoForEntity,
+  'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+>;
+
+export type IPageInfoBasic = IPageInfoBasicForEmpty | IPageInfoBasicForEntity;
+
 export const isIPageInfoForOperation = (
 export const isIPageInfoForOperation = (
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   pageInfo: any | undefined,
   pageInfo: any | undefined,