From 3eb29e2a4632be4d4aed2011475b963f00271e39 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sat, 9 May 2026 16:48:13 +0100
Subject: [PATCH] feat: page details section and backlinks
---
.../public/locales/en-US/translation.json | 15 +-
.../src/components/layouts/global/aside.tsx | 5 +
.../layouts/global/global-app-shell.tsx | 4 +-
.../global/hooks/atoms/sidebar-atom.ts | 1 +
.../components/page-verification-modal.tsx | 12 +-
.../src/features/editor/full-editor.tsx | 21 +-
.../features/editor/styles/editor.module.css | 12 +
.../components/backlinks-list.tsx | 113 +++++++++
.../components/backlinks-modal.tsx | 62 +++++
.../components/page-details-aside.tsx | 233 ++++++++++++++++++
.../page-details/queries/backlinks-query.ts | 45 ++++
.../services/backlinks-service.ts | 26 ++
.../page-details/types/backlink.types.ts | 24 ++
apps/server/src/core/page/dto/backlink.dto.ts | 11 +
apps/server/src/core/page/page.controller.ts | 39 +++
apps/server/src/core/page/page.module.ts | 15 +-
.../page/services/backlink.service.spec.ts | 163 ++++++++++++
.../core/page/services/backlink.service.ts | 56 +++++
.../database/repos/backlink/backlink.repo.ts | 89 ++++++-
packages/editor-ext/src/lib/table/cell.ts | 2 +-
packages/editor-ext/src/lib/table/header.ts | 2 +-
21 files changed, 940 insertions(+), 10 deletions(-)
create mode 100644 apps/client/src/features/page-details/components/backlinks-list.tsx
create mode 100644 apps/client/src/features/page-details/components/backlinks-modal.tsx
create mode 100644 apps/client/src/features/page-details/components/page-details-aside.tsx
create mode 100644 apps/client/src/features/page-details/queries/backlinks-query.ts
create mode 100644 apps/client/src/features/page-details/services/backlinks-service.ts
create mode 100644 apps/client/src/features/page-details/types/backlink.types.ts
create mode 100644 apps/server/src/core/page/dto/backlink.dto.ts
create mode 100644 apps/server/src/core/page/services/backlink.service.spec.ts
create mode 100644 apps/server/src/core/page/services/backlink.service.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index f831289b..905fde70 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -970,5 +970,18 @@
"Experimental": "Experimental",
"Strikethrough": "Strikethrough",
"Undo": "Undo",
- "Redo": "Redo"
+ "Redo": "Redo",
+ "Backlinks": "Backlinks",
+ "Last updated by": "Last updated by",
+ "Last updated": "Last updated",
+ "Stats": "Stats",
+ "Word count": "Word count",
+ "Characters": "Characters",
+ "Incoming links": "Incoming links",
+ "Outgoing links": "Outgoing links",
+ "Incoming links ({{count}})": "Incoming links ({{count}})",
+ "Outgoing links ({{count}})": "Outgoing links ({{count}})",
+ "No pages link here yet.": "No pages link here yet.",
+ "This page doesn't link to other pages yet.": "This page doesn't link to other pages yet.",
+ "Verified until {{date}}": "Verified until {{date}}"
}
diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx
index 4f2cf592..73e6a381 100644
--- a/apps/client/src/components/layouts/global/aside.tsx
+++ b/apps/client/src/components/layouts/global/aside.tsx
@@ -8,6 +8,7 @@ import { TableOfContents } from "@/features/editor/components/table-of-contents/
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
+import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
@@ -30,6 +31,10 @@ export default function Aside() {
component = ;
title = "AI Chat";
break;
+ case "details":
+ component = ;
+ title = "Details";
+ break;
default:
component = null;
title = null;
diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx
index d3d9ebcd..4c56fe07 100644
--- a/apps/client/src/components/layouts/global/global-app-shell.tsx
+++ b/apps/client/src/components/layouts/global/global-app-shell.tsx
@@ -147,7 +147,9 @@ export default function GlobalAppShell({
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
- : undefined
+ : asideTab === "details"
+ ? t("Details")
+ : undefined
}
>
diff --git a/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts b/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts
index 0e8b78a0..6cac2192 100644
--- a/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts
+++ b/apps/client/src/components/layouts/global/hooks/atoms/sidebar-atom.ts
@@ -10,6 +10,7 @@ export const desktopSidebarAtom = atomWithWebStorage(
export const desktopAsideAtom = atom(false);
+// Valid `tab` values: "" | "comments" | "toc" | "chat" | "details"
type AsideStateType = {
tab: string;
isAsideOpen: boolean;
diff --git a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx
index 2a59e566..b7278b6e 100644
--- a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx
+++ b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx
@@ -118,10 +118,20 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null;
+ const tooltipLabel =
+ status === "verified" && verificationInfo?.expiresAt
+ ? t("Verified until {{date}}", {
+ date: new Date(verificationInfo.expiresAt).toLocaleDateString(
+ undefined,
+ { month: "long", day: "numeric", year: "numeric" },
+ ),
+ })
+ : getStatusLabel(status, t);
+
return (
<>
{status !== "none" ? (
-
+
c.id !== creator?.id,
@@ -110,8 +116,8 @@ function PageByline({
{creator && (
@@ -173,6 +179,17 @@ function PageByline({
)}
+
+ toggleAside("details")}
+ >
+
+
+
+
);
diff --git a/apps/client/src/features/editor/styles/editor.module.css b/apps/client/src/features/editor/styles/editor.module.css
index dfe7393f..ed5f8643 100644
--- a/apps/client/src/features/editor/styles/editor.module.css
+++ b/apps/client/src/features/editor/styles/editor.module.css
@@ -9,3 +9,15 @@
}
}
+.byline {
+ padding-left: 3rem;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding-left: 1rem;
+ }
+
+ @media print {
+ padding-left: 0;
+ }
+}
+
diff --git a/apps/client/src/features/page-details/components/backlinks-list.tsx b/apps/client/src/features/page-details/components/backlinks-list.tsx
new file mode 100644
index 00000000..a0e1a4aa
--- /dev/null
+++ b/apps/client/src/features/page-details/components/backlinks-list.tsx
@@ -0,0 +1,113 @@
+import {
+ Button,
+ Center,
+ Group,
+ Loader,
+ Stack,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useBacklinksQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import {
+ BacklinkDirection,
+ IBacklinkPageItem,
+} from "@/features/page-details/types/backlink.types.ts";
+import { buildPageUrl } from "@/features/page/page.utils.ts";
+import { getPageIcon } from "@/lib";
+
+interface BacklinksListProps {
+ pageId: string;
+ direction: BacklinkDirection;
+ enabled: boolean;
+ onItemClick: () => void;
+}
+
+export function BacklinksList({
+ pageId,
+ direction,
+ enabled,
+ onItemClick,
+}: BacklinksListProps) {
+ const { t } = useTranslation();
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useBacklinksQuery(pageId, direction, enabled);
+
+ if (!enabled) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const items: IBacklinkPageItem[] =
+ data?.pages.flatMap((page) => page.items) ?? [];
+
+ if (items.length === 0) {
+ return (
+
+ {direction === "incoming"
+ ? t("No pages link here yet.")
+ : t("This page doesn't link to other pages yet.")}
+
+ );
+ }
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
+ return;
+ }
+ onItemClick();
+ };
+
+ return (
+
+ {items.map((item) => (
+
+
+ {getPageIcon(item.icon ?? "")}
+
+
+ {item.title || t("Untitled")}
+
+ {item.spaceName && (
+
+ {item.spaceName}
+
+ )}
+
+
+
+ ))}
+ {hasNextPage && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/backlinks-modal.tsx b/apps/client/src/features/page-details/components/backlinks-modal.tsx
new file mode 100644
index 00000000..83fc3114
--- /dev/null
+++ b/apps/client/src/features/page-details/components/backlinks-modal.tsx
@@ -0,0 +1,62 @@
+import { Modal, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksList } from "./backlinks-list";
+
+interface BacklinksModalProps {
+ pageId: string;
+ opened: boolean;
+ onClose: () => void;
+}
+
+export function BacklinksModal({
+ pageId,
+ opened,
+ onClose,
+}: BacklinksModalProps) {
+ const { t } = useTranslation();
+ const { data: counts } = useBacklinksCountQuery(pageId);
+
+ return (
+
+
+
+
+ {t("Backlinks")}
+
+
+
+
+
+
+ {t("Incoming links ({{count}})", {
+ count: counts?.incoming ?? 0,
+ })}
+
+
+
+
+
+
+ {t("Outgoing links ({{count}})", {
+ count: counts?.outgoing ?? 0,
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/page-details-aside.tsx b/apps/client/src/features/page-details/components/page-details-aside.tsx
new file mode 100644
index 00000000..7435f9e6
--- /dev/null
+++ b/apps/client/src/features/page-details/components/page-details-aside.tsx
@@ -0,0 +1,233 @@
+import {
+ Divider,
+ Group,
+ Skeleton,
+ Stack,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconChevronRight } from "@tabler/icons-react";
+import { useDisclosure } from "@mantine/hooks";
+import { useAtomValue } from "jotai";
+import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { extractPageSlugId } from "@/lib";
+import { usePageQuery } from "@/features/page/queries/page-query.ts";
+import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksModal } from "./backlinks-modal";
+import { formattedDate, timeAgo } from "@/lib/time.ts";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+
+export function PageDetailsAside() {
+ const { pageSlug } = useParams();
+ const { data: page } = usePageQuery({
+ pageId: extractPageSlugId(pageSlug),
+ });
+ const pageEditor = useAtomValue(pageEditorAtom);
+ const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
+ const [modalOpened, { open: openModal, close: closeModal }] =
+ useDisclosure(false);
+
+ if (!page) return null;
+
+ const wordCount: number =
+ pageEditor?.storage?.characterCount?.words?.() ?? 0;
+ const characterCount: number =
+ pageEditor?.storage?.characterCount?.characters?.() ?? 0;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function PeopleSection({
+ creator,
+ lastUpdatedBy,
+}: {
+ creator: { id: string; name: string; avatarUrl: string } | null;
+ lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ );
+}
+
+function PersonRow({
+ label,
+ person,
+}: {
+ label: string;
+ person: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ return (
+
+
+ {label}
+
+ {person ? (
+
+
+
+ {person.name}
+
+
+ ) : (
+
+ —
+
+ )}
+
+ );
+}
+
+function StatsSection({
+ wordCount,
+ characterCount,
+ createdAt,
+ updatedAt,
+}: {
+ wordCount: number;
+ characterCount: number;
+ createdAt: Date | string;
+ updatedAt: Date | string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Stats")}
+
+
+
+
+
+
+ );
+}
+
+function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+function BacklinksSection({
+ incomingCount,
+ outgoingCount,
+ isLoading,
+ onClick,
+}: {
+ incomingCount: number;
+ outgoingCount: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Backlinks")}
+
+
+
+
+ );
+}
+
+function BacklinksRow({
+ label,
+ count,
+ isLoading,
+ onClick,
+}: {
+ label: string;
+ count: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {isLoading ? (
+
+ ) : (
+ {count}
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/queries/backlinks-query.ts b/apps/client/src/features/page-details/queries/backlinks-query.ts
new file mode 100644
index 00000000..a5e4619e
--- /dev/null
+++ b/apps/client/src/features/page-details/queries/backlinks-query.ts
@@ -0,0 +1,45 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import {
+ getBacklinks,
+ getBacklinksCount,
+} from "@/features/page-details/services/backlinks-service.ts";
+import {
+ BacklinkDirection,
+ IBacklinkCount,
+} from "@/features/page-details/types/backlink.types.ts";
+
+const BACKLINKS_STALE_TIME = 30 * 1000;
+const BACKLINKS_PAGE_LIMIT = 100;
+
+export function useBacklinksCountQuery(pageId: string | undefined) {
+ return useQuery({
+ queryKey: ["backlinks-count", pageId],
+ queryFn: () => getBacklinksCount(pageId as string),
+ enabled: !!pageId,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
+
+export function useBacklinksQuery(
+ pageId: string | undefined,
+ direction: BacklinkDirection,
+ enabled: boolean,
+) {
+ return useInfiniteQuery({
+ queryKey: ["backlinks", pageId, direction],
+ queryFn: ({ pageParam }) =>
+ getBacklinks({
+ pageId: pageId as string,
+ direction,
+ cursor: pageParam,
+ limit: BACKLINKS_PAGE_LIMIT,
+ }),
+ enabled: enabled && !!pageId,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.meta.hasNextPage
+ ? (lastPage.meta.nextCursor ?? undefined)
+ : undefined,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
diff --git a/apps/client/src/features/page-details/services/backlinks-service.ts b/apps/client/src/features/page-details/services/backlinks-service.ts
new file mode 100644
index 00000000..779911ee
--- /dev/null
+++ b/apps/client/src/features/page-details/services/backlinks-service.ts
@@ -0,0 +1,26 @@
+import api from "@/lib/api-client";
+import { IPagination } from "@/lib/types.ts";
+import {
+ IBacklinkCount,
+ IBacklinkPageItem,
+ IBacklinksListParams,
+} from "@/features/page-details/types/backlink.types.ts";
+
+export async function getBacklinksCount(
+ pageId: string,
+): Promise {
+ const req = await api.post("/pages/backlinks-count", {
+ pageId,
+ });
+ return req.data;
+}
+
+export async function getBacklinks(
+ params: IBacklinksListParams,
+): Promise> {
+ const req = await api.post>(
+ "/pages/backlinks",
+ params,
+ );
+ return req.data;
+}
diff --git a/apps/client/src/features/page-details/types/backlink.types.ts b/apps/client/src/features/page-details/types/backlink.types.ts
new file mode 100644
index 00000000..2fb53218
--- /dev/null
+++ b/apps/client/src/features/page-details/types/backlink.types.ts
@@ -0,0 +1,24 @@
+export type BacklinkDirection = "incoming" | "outgoing";
+
+export interface IBacklinkCount {
+ incoming: number;
+ outgoing: number;
+}
+
+export interface IBacklinkPageItem {
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ spaceSlug: string | null;
+ spaceName: string | null;
+ updatedAt: string;
+}
+
+export interface IBacklinksListParams {
+ pageId: string;
+ direction: BacklinkDirection;
+ cursor?: string;
+ limit?: number;
+}
diff --git a/apps/server/src/core/page/dto/backlink.dto.ts b/apps/server/src/core/page/dto/backlink.dto.ts
new file mode 100644
index 00000000..7f0a59b3
--- /dev/null
+++ b/apps/server/src/core/page/dto/backlink.dto.ts
@@ -0,0 +1,11 @@
+import { IsIn, IsNotEmpty, IsString } from 'class-validator';
+import { PageIdDto } from './page.dto';
+
+export type BacklinkDirection = 'incoming' | 'outgoing';
+
+export class BacklinksListDto extends PageIdDto {
+ @IsString()
+ @IsNotEmpty()
+ @IsIn(['incoming', 'outgoing'])
+ direction: BacklinkDirection;
+}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index fa279d85..67e005a0 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
+import { BacklinkService } from './services/backlink.service';
import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
@@ -38,6 +39,7 @@ import { RecentPageDto } from './dto/recent-page.dto';
import { CreatedByUserDto } from './dto/created-by-user.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
+import { BacklinksListDto } from './dto/backlink.dto';
import {
jsonToHtml,
jsonToMarkdown,
@@ -58,6 +60,7 @@ export class PageController {
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
+ private readonly backlinkService: BacklinkService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -96,6 +99,42 @@ export class PageController {
return { ...page, permissions };
}
+ @HttpCode(HttpStatus.OK)
+ @Post('backlinks-count')
+ async getBacklinksCount(
+ @Body() dto: PageIdDto,
+ @AuthUser() user: User,
+ ): Promise<{ incoming: number; outgoing: number }> {
+ const page = await this.pageRepo.findById(dto.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
+ }
+ await this.pageAccessService.validateCanView(page, user);
+
+ return this.backlinkService.countByPageId(page.id, user.id);
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('backlinks')
+ async getBacklinks(
+ @Body() dto: BacklinksListDto,
+ @Body() pagination: PaginationOptions,
+ @AuthUser() user: User,
+ ) {
+ const page = await this.pageRepo.findById(dto.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
+ }
+ await this.pageAccessService.validateCanView(page, user);
+
+ return this.backlinkService.findByPageId(
+ page.id,
+ dto.direction,
+ user.id,
+ pagination,
+ );
+ }
+
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts
index 20f3b68e..b54b3646 100644
--- a/apps/server/src/core/page/page.module.ts
+++ b/apps/server/src/core/page/page.module.ts
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service';
+import { BacklinkService } from './services/backlink.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
@@ -10,8 +11,18 @@ import { TransclusionModule } from './transclusion/transclusion.module';
@Module({
controllers: [PageController],
- providers: [PageService, PageHistoryService, TrashCleanupService],
+ providers: [
+ PageService,
+ PageHistoryService,
+ TrashCleanupService,
+ BacklinkService,
+ ],
exports: [PageService, PageHistoryService],
- imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
+ imports: [
+ StorageModule,
+ CollaborationModule,
+ WatcherModule,
+ TransclusionModule,
+ ],
})
export class PageModule {}
diff --git a/apps/server/src/core/page/services/backlink.service.spec.ts b/apps/server/src/core/page/services/backlink.service.spec.ts
new file mode 100644
index 00000000..5007e60f
--- /dev/null
+++ b/apps/server/src/core/page/services/backlink.service.spec.ts
@@ -0,0 +1,163 @@
+import { Test } from '@nestjs/testing';
+import { BacklinkService } from './backlink.service';
+import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+
+describe('BacklinkService.countByPageId', () => {
+ let service: BacklinkService;
+ let backlinkRepo: jest.Mocked;
+ let permissionRepo: jest.Mocked;
+
+ const pageId = '00000000-0000-0000-0000-000000000001';
+ const userId = '00000000-0000-0000-0000-000000000099';
+
+ beforeEach(async () => {
+ const backlinkRepoMock: jest.Mocked> = {
+ findRelatedPageIds: jest.fn(),
+ findPagesByIdsPaginated: jest.fn(),
+ };
+ const permissionRepoMock: jest.Mocked> = {
+ filterAccessiblePageIds: jest.fn(),
+ };
+
+ const module = await Test.createTestingModule({
+ providers: [
+ BacklinkService,
+ { provide: BacklinkRepo, useValue: backlinkRepoMock },
+ { provide: PagePermissionRepo, useValue: permissionRepoMock },
+ ],
+ }).compile();
+
+ service = module.get(BacklinkService);
+ backlinkRepo = module.get(BacklinkRepo) as jest.Mocked;
+ permissionRepo = module.get(
+ PagePermissionRepo,
+ ) as jest.Mocked;
+ });
+
+ it('returns post-filter counts for both directions', async () => {
+ backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
+ dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
+ );
+ permissionRepo.filterAccessiblePageIds.mockImplementation(
+ async ({ pageIds }) =>
+ pageIds.filter((id) => id !== 'b' && id !== 'y'),
+ );
+
+ const result = await service.countByPageId(pageId, userId);
+
+ expect(result).toEqual({ incoming: 2, outgoing: 1 });
+ expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
+ pageIds: ['a', 'b', 'c'],
+ userId,
+ });
+ expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
+ pageIds: ['x', 'y'],
+ userId,
+ });
+ });
+
+ it('skips the permission filter when there are no candidates', async () => {
+ backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
+ permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
+
+ const result = await service.countByPageId(pageId, userId);
+
+ expect(result).toEqual({ incoming: 0, outgoing: 0 });
+ expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
+ });
+
+ it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
+ backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
+
+ await service.countByPageId(pageId, userId);
+
+ expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
+ pageId,
+ 'incoming',
+ userId,
+ );
+ expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
+ pageId,
+ 'outgoing',
+ userId,
+ );
+ });
+});
+
+describe('BacklinkService.findByPageId', () => {
+ let service: BacklinkService;
+ let backlinkRepo: jest.Mocked;
+ let permissionRepo: jest.Mocked;
+
+ const pageId = '00000000-0000-0000-0000-000000000001';
+ const userId = '00000000-0000-0000-0000-000000000099';
+
+ beforeEach(async () => {
+ const backlinkRepoMock: jest.Mocked> = {
+ findRelatedPageIds: jest.fn(),
+ findPagesByIdsPaginated: jest.fn(),
+ };
+ const permissionRepoMock: jest.Mocked> = {
+ filterAccessiblePageIds: jest.fn(),
+ };
+
+ const module = await Test.createTestingModule({
+ providers: [
+ BacklinkService,
+ { provide: BacklinkRepo, useValue: backlinkRepoMock },
+ { provide: PagePermissionRepo, useValue: permissionRepoMock },
+ ],
+ }).compile();
+
+ service = module.get(BacklinkService);
+ backlinkRepo = module.get(BacklinkRepo) as jest.Mocked;
+ permissionRepo = module.get(
+ PagePermissionRepo,
+ ) as jest.Mocked;
+ });
+
+ it('passes filtered ids through to the paginated repo call', async () => {
+ backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
+ permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
+ backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
+ items: [],
+ meta: {
+ limit: 20,
+ hasNextPage: false,
+ hasPrevPage: false,
+ nextCursor: null,
+ prevCursor: null,
+ },
+ } as any);
+
+ await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
+
+ expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
+ ['a'],
+ expect.objectContaining({ limit: 20 }),
+ );
+ });
+
+ it('hands an empty list to the repo when there are no accessible ids', async () => {
+ backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
+ backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
+ items: [],
+ meta: {
+ limit: 20,
+ hasNextPage: false,
+ hasPrevPage: false,
+ nextCursor: null,
+ prevCursor: null,
+ },
+ } as any);
+
+ await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
+
+ expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
+ [],
+ expect.objectContaining({ limit: 20 }),
+ );
+ expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/server/src/core/page/services/backlink.service.ts b/apps/server/src/core/page/services/backlink.service.ts
new file mode 100644
index 00000000..dcb0569c
--- /dev/null
+++ b/apps/server/src/core/page/services/backlink.service.ts
@@ -0,0 +1,56 @@
+import { Injectable } from '@nestjs/common';
+import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+
+export type BacklinkDirection = 'incoming' | 'outgoing';
+
+@Injectable()
+export class BacklinkService {
+ constructor(
+ private readonly backlinkRepo: BacklinkRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
+ ) {}
+
+ async countByPageId(
+ pageId: string,
+ userId: string,
+ ): Promise<{ incoming: number; outgoing: number }> {
+ const [incomingIds, outgoingIds] = await Promise.all([
+ this.accessibleRelatedIds(pageId, 'incoming', userId),
+ this.accessibleRelatedIds(pageId, 'outgoing', userId),
+ ]);
+ return { incoming: incomingIds.length, outgoing: outgoingIds.length };
+ }
+
+ async findByPageId(
+ pageId: string,
+ direction: BacklinkDirection,
+ userId: string,
+ pagination: PaginationOptions,
+ ) {
+ const accessibleIds = await this.accessibleRelatedIds(
+ pageId,
+ direction,
+ userId,
+ );
+ return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
+ }
+
+ private async accessibleRelatedIds(
+ pageId: string,
+ direction: BacklinkDirection,
+ userId: string,
+ ): Promise {
+ const candidateIds = await this.backlinkRepo.findRelatedPageIds(
+ pageId,
+ direction,
+ userId,
+ );
+ if (candidateIds.length === 0) return [];
+ return this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds: candidateIds,
+ userId,
+ });
+ }
+}
diff --git a/apps/server/src/database/repos/backlink/backlink.repo.ts b/apps/server/src/database/repos/backlink/backlink.repo.ts
index 43c01065..9c23e554 100644
--- a/apps/server/src/database/repos/backlink/backlink.repo.ts
+++ b/apps/server/src/database/repos/backlink/backlink.repo.ts
@@ -7,10 +7,19 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
+import {
+ executeWithCursorPagination,
+ emptyCursorPaginationResult,
+} from '@docmost/db/pagination/cursor-pagination';
+import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class BacklinkRepo {
- constructor(@InjectKysely() private readonly db: KyselyDB) {}
+ constructor(
+ @InjectKysely() private readonly db: KyselyDB,
+ private readonly spaceMemberRepo: SpaceMemberRepo,
+ ) {}
async findById(
backlinkId: string,
@@ -69,4 +78,82 @@ export class BacklinkRepo {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
}
+
+ async findRelatedPageIds(
+ pageId: string,
+ direction: 'incoming' | 'outgoing',
+ userId: string,
+ ): Promise {
+ const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
+
+ if (direction === 'incoming') {
+ const rows = await this.db
+ .selectFrom('backlinks')
+ .innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
+ .select('backlinks.sourcePageId as relatedId')
+ .where('backlinks.targetPageId', '=', pageId)
+ .where('pages.deletedAt', 'is', null)
+ .where('pages.spaceId', 'in', userSpaceIds)
+ .execute();
+ return rows.map((r) => r.relatedId);
+ }
+
+ const rows = await this.db
+ .selectFrom('backlinks')
+ .innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
+ .select('backlinks.targetPageId as relatedId')
+ .where('backlinks.sourcePageId', '=', pageId)
+ .where('pages.deletedAt', 'is', null)
+ .where('pages.spaceId', 'in', userSpaceIds)
+ .execute();
+ return rows.map((r) => r.relatedId);
+ }
+
+ async findPagesByIdsPaginated(
+ pageIds: string[],
+ pagination: PaginationOptions,
+ ) {
+ if (pageIds.length === 0) {
+ return emptyCursorPaginationResult<{
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ updatedAt: Date;
+ spaceSlug: string | null;
+ spaceName: string | null;
+ }>(pagination.limit);
+ }
+
+ const query = this.db
+ .selectFrom('pages')
+ .leftJoin('spaces', 'spaces.id', 'pages.spaceId')
+ .select([
+ 'pages.id',
+ 'pages.slugId',
+ 'pages.title',
+ 'pages.icon',
+ 'pages.spaceId',
+ 'pages.updatedAt',
+ 'spaces.slug as spaceSlug',
+ 'spaces.name as spaceName',
+ ])
+ .where('pages.deletedAt', 'is', null)
+ .where('pages.id', 'in', pageIds);
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
+ { expression: 'pages.id', direction: 'desc', key: 'id' },
+ ],
+ parseCursor: (cursor) => ({
+ updatedAt: new Date(cursor.updatedAt),
+ id: cursor.id,
+ }),
+ });
+ }
}
diff --git a/packages/editor-ext/src/lib/table/cell.ts b/packages/editor-ext/src/lib/table/cell.ts
index 2f693573..9101e161 100644
--- a/packages/editor-ext/src/lib/table/cell.ts
+++ b/packages/editor-ext/src/lib/table/cell.ts
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content:
- "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
+ "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
diff --git a/packages/editor-ext/src/lib/table/header.ts b/packages/editor-ext/src/lib/table/header.ts
index 77ab02f1..7f288dde 100644
--- a/packages/editor-ext/src/lib/table/header.ts
+++ b/packages/editor-ext/src/lib/table/header.ts
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content:
- "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
+ "(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {