feat: internal page links and mentions (#604)

* Work on mentions

* fix: properly parse page slug

* fix editor suggestion bugs

* mentions must start with whitespace

* add icon to page mention render

* feat: backlinks - WIP

* UI - WIP

* permissions check
* use FTS for page suggestion

* cleanup

* WIP

* page title fallback

* feat: handle internal link paste

* link styling

* WIP

* Switch back to LIKE operator for search suggestion

* WIP
* scope to workspaceId
* still create link for pages not found

* select necessary columns

* cleanups
This commit is contained in:
Philip Okugbe 2025-02-14 15:36:44 +00:00 committed by GitHub
parent 0ef6b1978a
commit e209aaa272
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1679 additions and 101 deletions

View File

@ -2,12 +2,42 @@ import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
export const handleFilePaste = (
export const handlePaste = (
view: EditorView,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
) => {
const clipboardData = event.clipboardData.getData("text/plain");
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
const { from: pos, empty } = view.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
// pasted link must be from the same workspace/domain and must not be on a selection
if (!empty || match[2] !== window.location.host) {
// allow the default link extension to handle this
return false;
}
// for now, we only support internal links from the same space
// compare space name
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
return false;
}
createMentionAction(url, view, pos, creatorId);
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
const [file] = Array.from(event.clipboardData.files);

View File

@ -0,0 +1,74 @@
import { EditorView } from "@tiptap/pm/view";
import { getPageById } from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { v7 } from "uuid";
import { extractPageSlugId } from "@/lib";
export type LinkFn = (
url: string,
view: EditorView,
pos: number,
creatorId: string,
) => void;
export interface InternalLinkOptions {
validateFn: (url: string, view: EditorView) => boolean;
onResolveLink: (linkedPageId: string, creatorId: string) => Promise<any>;
}
export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => {
const validated = validateFn(url, view);
if (!validated) return;
const linkedPageId = extractPageSlugId(url);
await onResolveLink(linkedPageId, creatorId).then(
(page: IPage) => {
const { schema } = view.state;
const node = schema.nodes.mention.create({
id: v7(),
label: page.title || "Untitled",
entityType: "page",
entityId: page.id,
slugId: page.slugId,
creatorId: creatorId,
});
if (!node) return;
const transaction = view.state.tr.replaceWith(pos, pos, node);
view.dispatch(transaction);
},
() => {
// on failure, insert as normal link
const { schema } = view.state;
const transaction = view.state.tr.insertText(url, pos);
transaction.addMark(
pos,
pos + url.length,
schema.marks.link.create({ href: url }),
);
view.dispatch(transaction);
},
);
};
export const createMentionAction = handleInternalLink({
onResolveLink: async (linkedPageId: string): Promise<any> => {
// eslint-disable-next-line no-useless-catch
try {
return await getPageById({ pageId: linkedPageId });
} catch (err) {
throw err;
}
},
validateFn: (url: string, view: EditorView) => {
// validation is already done on the paste handler
return true;
},
});

View File

@ -8,6 +8,7 @@ import {
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./link.module.css";
export type LinkPreviewPanelProps = {
url: string;
@ -31,12 +32,7 @@ export const LinkPreviewPanel = ({
href={url}
target="_blank"
rel="noopener noreferrer"
inherit
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
className={classes.link}
>
{url}
</Anchor>

View File

@ -0,0 +1,6 @@
.link {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,273 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import {
ActionIcon,
Group,
Paper,
ScrollArea,
Text,
UnstyledButton,
} from "@mantine/core";
import clsx from "clsx";
import classes from "./mention.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { IconFileDescription } from "@tabler/icons-react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { v7 as uuid7 } from "uuid";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
MentionListProps,
MentionSuggestionItem,
} from "@/features/editor/components/mention/mention.type.ts";
const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom);
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query,
includeUsers: true,
includePages: true,
spaceId: space.id,
limit: 10,
});
useEffect(() => {
if (suggestion && !isLoading) {
let items: MentionSuggestionItem[] = [];
if (suggestion?.users?.length > 0) {
items.push({ entityType: "header", label: "Users" });
items = items.concat(
suggestion.users.map((user) => ({
id: uuid7(),
label: user.name,
entityType: "user",
entityId: user.id,
avatarUrl: user.avatarUrl,
})),
);
}
if (suggestion?.pages?.length > 0) {
items.push({ entityType: "header", label: "Pages" });
items = items.concat(
suggestion.pages.map((page) => ({
id: uuid7(),
label: page.title || "Untitled",
entityType: "page",
entityId: page.id,
slugId: page.slugId,
icon: page.icon,
})),
);
}
setRenderItems(items);
// update editor storage
props.editor.storage.mentionItems = items;
}
}, [suggestion, isLoading]);
const selectItem = useCallback(
(index: number) => {
const item = renderItems?.[index];
if (item) {
if (item.entityType === "user") {
props.command({
id: item.id,
label: item.label,
entityType: "user",
entityId: item.entityId,
creatorId: currentUser?.user.id,
});
}
if (item.entityType === "page") {
props.command({
id: item.id,
label: item.label || "Untitled",
entityType: "page",
entityId: item.entityId,
slugId: item.slugId,
creatorId: currentUser?.user.id,
});
}
}
},
[renderItems],
);
const upHandler = () => {
if (!renderItems.length) return;
let newIndex = selectedIndex;
do {
newIndex = (newIndex + renderItems.length - 1) % renderItems.length;
} while (renderItems[newIndex].entityType === "header");
setSelectedIndex(newIndex);
};
const downHandler = () => {
if (!renderItems.length) return;
let newIndex = selectedIndex;
do {
newIndex = (newIndex + 1) % renderItems.length;
} while (renderItems[newIndex].entityType === "header");
setSelectedIndex(newIndex);
};
const enterHandler = () => {
if (!renderItems.length) return;
if (renderItems[selectedIndex].entityType !== "header") {
selectItem(selectedIndex);
}
};
useEffect(() => {
setSelectedIndex(1);
}, [suggestion]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
// don't trap the enter button if there are no items to render
if (renderItems.length === 0) {
return false;
}
enterHandler();
return true;
}
return false;
},
}));
// if no results and enter what to do?
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (renderItems.length === 0) {
return (
<Paper shadow="md" p="xs" withBorder>
No results
</Paper>
);
}
return (
<Paper id="mention" shadow="md" p="xs" withBorder>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
w={320}
scrollbarSize={8}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
return (
<div key={`${item.label}-${index}`}>
<Text c="dimmed" mb={4} tt="uppercase">
{item.label}
</Text>
</div>
);
} else if (item.entityType === "user") {
return (
<UnstyledButton
data-item-index={index}
key={index}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
>
<Group>
<CustomAvatar
size={"sm"}
avatarUrl={item.avatarUrl}
name={item.label}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.label}
</Text>
</div>
</Group>
</UnstyledButton>
);
} else if (item.entityType === "page") {
return (
<UnstyledButton
data-item-index={index}
key={index}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
>
<Group>
<ActionIcon
variant="default"
component="div"
aria-label={item.label}
>
{item.icon || (
<ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.label}
</Text>
</div>
</Group>
</UnstyledButton>
);
} else {
return null;
}
})}
</ScrollArea.Autosize>
</Paper>
);
});
export default MentionList;

View File

@ -0,0 +1,113 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import tippy from "tippy.js";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
const matches = query?.match(/([\s]+)/g);
return matches?.length || 0;
}
const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
return;
}
// don't render component if space between the search query words is greater than 4
const whitespaceCount = getWhitespaceCount(props.query);
if (whitespaceCount > 4) {
return;
}
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
component?.destroy();
return;
}
// only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) {
component?.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
if (
whitespaceCount > 3 &&
props.editor.storage.mentionItems.length === 0
) {
popup?.[0]?.destroy();
component?.destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
popup?.[0].destroy();
component?.destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
},
};
};
export default mentionRenderItems;

View File

@ -0,0 +1,56 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug } = useParams();
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
return (
<NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && (
<Text className={classes.userMention} component="span">
@{label}
</Text>
)}
{entityType === "page" && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label)}
underline="never"
className={classes.pageMentionLink}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={classes.pageMentionText}>
{page?.title || label}
</span>
</Anchor>
)}
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,58 @@
.pageMentionLink {
color: light-dark(
var(--mantine-color-dark-4),
var(--mantine-color-dark-1)
) !important;
}
.pageMentionText {
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.userMention {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
font-weight: 500;
border-radius: 0.4rem;
box-decoration-break: clone;
padding: 0.1rem 0.3rem;
cursor: pointer;
&::after {
content: "\200B";
}
}
.menuBtn {
width: 100%;
padding: 4px;
margin-bottom: 2px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
.selectedItem {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}

View File

@ -0,0 +1,28 @@
import { Editor, Range } from "@tiptap/core";
export interface MentionListProps {
query: string;
command: any;
items: [];
range: Range;
text: string;
editor: Editor;
}
export type MentionSuggestionItem =
| { entityType: "header"; label: string }
| {
id: string;
label: string;
entityType: "user";
entityId: string;
avatarUrl: string;
}
| {
id: string;
label: string;
entityType: "page";
entityId: string;
slugId: string;
icon: string;
};

View File

@ -36,6 +36,7 @@ import {
Drawio,
Excalidraw,
Embed,
Mention,
} from "@docmost/editor-ext";
import {
randomElement,
@ -64,8 +65,11 @@ import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import i18n from "i18next";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@ -133,6 +137,23 @@ export const mainExtensions = [
class: "comment-mark",
},
}),
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => {
return [];
},
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
return ReactNodeViewRenderer(MentionView);
},
}),
Table.configure({
resizable: true,
lastColumnResizable: false,

View File

@ -35,8 +35,8 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import {
handleFileDrop,
handleFilePaste,
} from "@/features/editor/components/common/file-upload-handler.tsx";
handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
@ -138,7 +138,8 @@ export default function PageEditor({
}
},
},
handlePaste: (view, event) => handleFilePaste(view, event, pageId),
handlePaste: (view, event, slice) =>
handlePaste(view, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId),
},

View File

@ -56,8 +56,14 @@
}
a {
color: light-dark(#207af1, #587da9);
/*font-weight: bold;*/
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
/*font-weight: 500; */
text-decoration: none;
cursor: pointer;
}

View File

@ -9,3 +9,5 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./mention.css";

View File

@ -0,0 +1,5 @@
.node-mention {
&.ProseMirror-selectednode {
outline: none;
}
}

View File

@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery(
params: SearchSuggestionParams,
): UseQueryResult<ISuggestionResult, Error> {
return useQuery({
queryKey: ["search-suggestion", params],
queryKey: ["search-suggestion", params.query],
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(params),
enabled: !!params.query,
});

View File

@ -1,11 +1,12 @@
import { Group, Center, Text } from "@mantine/core";
import { Spotlight } from "@mantine/spotlight";
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
import { IconSearch } from "@tabler/icons-react";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useDebouncedValue } from "@mantine/hooks";
import { usePageSearchQuery } from "@/features/search/queries/search-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
interface SearchSpotlightProps {
@ -33,13 +34,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}
>
<Group wrap="nowrap" w="100%">
<Center>
{page?.icon ? (
<span style={{ fontSize: "20px" }}>{page.icon}</span>
) : (
<IconFileDescription size={20} />
)}
</Center>
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>

View File

@ -1,6 +1,7 @@
import { IUser } from "@/features/user/types/user.types.ts";
import { IGroup } from "@/features/group/types/group.types.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
export interface IPageSearch {
id: string;
@ -20,11 +21,15 @@ export interface SearchSuggestionParams {
query: string;
includeUsers?: boolean;
includeGroups?: boolean;
includePages?: boolean;
spaceId?: string;
limit?: number;
}
export interface ISuggestionResult {
users?: Partial<IUser[]>;
groups?: Partial<IGroup[]>;
pages?: Partial<IPage[]>;
}
export interface IPageSearchParams {

View File

@ -0,0 +1,2 @@
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;

View File

@ -1,3 +1,7 @@
import { validate as isValidUUID } from "uuid";
import { ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { ReactNode } from "react";
import { TFunction } from "i18next";
export function formatMemberCount(memberCount: number, t: TFunction): string {
@ -8,12 +12,15 @@ export function formatMemberCount(memberCount: number, t: TFunction): string {
}
}
export function extractPageSlugId(input: string): string {
if (!input) {
export function extractPageSlugId(slug: string): string {
if (!slug) {
return undefined;
}
const parts = input.split("-");
return parts.length > 1 ? parts[parts.length - 1] : input;
if (isValidUUID(slug)) {
return slug;
}
const parts = slug.split("-");
return parts.length > 1 ? parts[parts.length - 1] : slug;
}
export const computeSpaceSlug = (name: string) => {
@ -76,3 +83,13 @@ export function decodeBase64ToSvgString(base64Data: string): string {
export function capitalizeFirstChar(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export function getPageIcon(icon: string, size = 18): string | ReactNode {
return (
icon || (
<ActionIcon variant="transparent" color="gray" size={size}>
<IconFileDescription size={size} />
</ActionIcon>
)
);
}

View File

@ -20,6 +20,7 @@ export default function Page() {
data: page,
isLoading,
isError,
error,
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
@ -31,7 +32,9 @@ export default function Page() {
}
if (isError || !page) {
// TODO: fix this
if ([401, 403, 404].includes(error?.["status"])) {
return <div>{t("Page not found")}</div>;
}
return <div>{t("Error fetching page data.")}</div>;
}

View File

@ -31,6 +31,7 @@ import {
Drawio,
Excalidraw,
Embed,
Mention
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@ -75,6 +76,7 @@ export const tiptapExtensions = [
Drawio,
Excalidraw,
Embed,
Mention
] as any;
export function jsonToHtml(tiptapJson: any) {

View File

@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
@Injectable()
export class PersistenceExtension implements Extension {
@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension {
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension {
this.logger.warn('jsonToText' + err?.['message']);
}
try {
let page = null;
let page: Page = null;
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension {
return;
}
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension {
pageId,
trx,
);
});
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
lastUpdatedById: context.user.id,
content: tiptapJson,
textContent: textContent,
},
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
} catch (err) {
this.logger.error(`Failed to update page ${pageId}`, err);
}
if (page) {
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
pageId: pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
} as IPageBacklinkJob);
}
}
}

View File

@ -0,0 +1,58 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
export interface MentionNode {
id: string;
label: string;
entityType: 'user' | 'page';
entityId: string;
creatorId: string;
}
export function extractMentions(prosemirrorJson: any) {
const mentionList: MentionNode[] = [];
const doc = jsonToNode(prosemirrorJson);
doc.descendants((node: Node) => {
if (node.type.name === 'mention') {
if (
node.attrs.id &&
!mentionList.some((mention) => mention.id === node.attrs.id)
) {
mentionList.push({
id: node.attrs.id,
label: node.attrs.label,
entityType: node.attrs.entityType,
entityId: node.attrs.entityId,
creatorId: node.attrs.creatorId,
});
}
}
});
return mentionList;
}
export function extractUserMentions(mentionList: MentionNode[]): MentionNode[] {
const userList = [];
for (const mention of mentionList) {
if (mention.entityType === 'user') {
userList.push(mention);
}
}
return userList as MentionNode[];
}
export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
const pageMentionList = [];
for (const mention of mentionList) {
if (
mention.entityType === 'page' &&
!pageMentionList.some(
(pageMention) => pageMention.entityId === mention.entityId,
)
) {
pageMentionList.push(mention);
}
}
return pageMentionList as MentionNode[];
}

View File

@ -44,3 +44,12 @@ export function createRetryStrategy() {
return Math.max(Math.min(Math.exp(times), 20000), 3000);
};
}
export function extractDateFromUuid7(uuid7: string) {
//https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/
const parts = uuid7.split('-');
const highBitsHex = parts[0] + parts[1].slice(0, 4);
const timestamp = parseInt(highBitsHex, 16);
return new Date(timestamp);
}

View File

@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types';
@Processor(QueueName.ATTACHEMENT_QUEUE)
@Processor(QueueName.ATTACHMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) {

View File

@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
@IsOptional()
@IsBoolean()
includeUsers?: string;
includeUsers?: boolean;
@IsOptional()
@IsBoolean()
includeGroups?: number;
includeGroups?: boolean;
@IsOptional()
@IsBoolean()
includePages?: boolean;
@IsOptional()
@IsString()
spaceId?: string;
@IsOptional()
@IsNumber()
limit?: number;
}

View File

@ -48,11 +48,13 @@ export class SearchController {
throw new NotImplementedException();
}
@HttpCode(HttpStatus.OK)
@Post('suggest')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchSuggestions(dto, workspace.id);
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
}
}

View File

@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@ -14,6 +15,7 @@ export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {}
async searchPage(
@ -29,15 +31,15 @@ export class SearchService {
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'slugId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as(
sql<string>`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
@ -66,35 +68,59 @@ export class SearchService {
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
workspaceId: string,
) {
const limit = 25;
const userSearch = this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
const groupSearch = this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
let users = [];
let groups = [];
let pages = [];
const limit = suggestion?.limit || 10;
const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) {
users = await userSearch.execute();
users = await this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
if (suggestion.includeGroups) {
groups = await groupSearch.execute();
groups = await this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
return { users, groups };
if (suggestion.includePages) {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
// only search spaces the user has access to
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
}
return { users, groups, pages };
}
}

View File

@ -24,7 +24,7 @@ export class SpaceService {
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async createSpace(

View File

@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
CommentRepo,
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
],
exports: [
WorkspaceRepo,
@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
CommentRepo,
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
],
})
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {

View File

@ -0,0 +1,33 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('backlinks')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('source_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('target_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('backlinks_source_page_id_target_page_id_unique', [
'source_page_id',
'target_page_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('backlinks').execute();
}

View File

@ -0,0 +1,72 @@
import {
Backlink,
InsertableBacklink,
UpdatableBacklink,
} from '@docmost/db/types/entity.types';
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';
@Injectable()
export class BacklinkRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
backlinkId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Backlink> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('backlinks')
.select([
'id',
'sourcePageId',
'targetPageId',
'workspaceId',
'createdAt',
'updatedAt',
])
.where('id', '=', backlinkId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insertBacklink(
insertableBacklink: InsertableBacklink,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.insertInto('backlinks')
.values(insertableBacklink)
.onConflict((oc) =>
oc.columns(['sourcePageId', 'targetPageId']).doNothing(),
)
.returningAll()
.executeTakeFirst();
}
async updateBacklink(
updatableBacklink: UpdatableBacklink,
backlinkId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('userTokens')
.set(updatableBacklink)
.where('id', '=', backlinkId)
.execute();
}
async deleteBacklink(
backlinkId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
}
}

View File

@ -166,7 +166,16 @@ export class PageRepo {
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
.select([
'id',
'slugId',
'title',
'icon',
'content',
'parentPageId',
'spaceId',
'workspaceId',
])
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
@ -179,6 +188,7 @@ export class PageRepo {
'p.content',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
])
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),

View File

@ -42,6 +42,15 @@ export interface Attachments {
workspaceId: string;
}
export interface Backlinks {
createdAt: Generated<Timestamp>;
id: Generated<string>;
sourcePageId: string;
targetPageId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Comments {
content: Json | null;
createdAt: Generated<Timestamp>;
@ -51,6 +60,7 @@ export interface Comments {
id: Generated<string>;
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
selection: string | null;
type: string | null;
workspaceId: string;
@ -59,6 +69,7 @@ export interface Comments {
export interface Groups {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
description: string | null;
id: Generated<string>;
isDefault: boolean;
@ -118,6 +129,7 @@ export interface Pages {
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
groupId: string | null;
id: Generated<string>;
role: string;
@ -135,7 +147,7 @@ export interface Spaces {
id: Generated<string>;
logo: string | null;
name: string | null;
slug: string | null;
slug: string;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
workspaceId: string;
@ -155,7 +167,7 @@ export interface Users {
locale: string | null;
name: string | null;
password: string | null;
role: string;
role: string | null;
settings: Json | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
@ -186,13 +198,13 @@ export interface WorkspaceInvitations {
}
export interface Workspaces {
allowedEmailDomains: Generated<string[] | null>;
createdAt: Generated<Timestamp>;
customDomain: string | null;
defaultRole: Generated<string>;
defaultSpaceId: string | null;
deletedAt: Timestamp | null;
description: string | null;
emailDomains: Generated<string[] | null>;
hostname: string | null;
id: Generated<string>;
logo: string | null;
@ -203,6 +215,7 @@ export interface Workspaces {
export interface DB {
attachments: Attachments;
backlinks: Backlinks;
comments: Comments;
groups: Groups;
groupUsers: GroupUsers;

View File

@ -12,6 +12,7 @@ import {
SpaceMembers,
WorkspaceInvitations,
UserTokens,
Backlinks,
} from './db';
// Workspace
@ -77,3 +78,8 @@ export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;
export type UserToken = Selectable<UserTokens>;
export type InsertableUserToken = Insertable<UserTokens>;
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
// Backlink
export type Backlink = Selectable<Backlinks>;
export type InsertableBacklink = Insertable<Backlink>;
export type UpdatableBacklink = Updateable<Omit<Backlink, 'id'>>;

View File

@ -76,7 +76,11 @@ export class ExportController {
return;
}
const rawContent = await this.exportService.exportPage(dto.format, page);
const rawContent = await this.exportService.exportPage(
dto.format,
page,
true,
);
res.headers({
'Content-Type': getMimeType(fileExt),

View File

@ -4,7 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { jsonToHtml } from '../../collaboration/collaboration.util';
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
import { turndown } from './turndown-utils';
import { ExportFormat } from './dto/export-dto';
import { Page } from '@docmost/db/types/entity.types';
@ -24,6 +24,11 @@ import {
updateAttachmentUrls,
} from './utils';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import { EnvironmentService } from '../environment/environment.service';
@Injectable()
export class ExportService {
@ -33,16 +38,27 @@ export class ExportService {
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
private readonly environmentService: EnvironmentService,
) {}
async exportPage(format: string, page: Page) {
async exportPage(format: string, page: Page, singlePage?: boolean) {
const titleNode = {
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: getPageTitle(page.title) }],
};
const prosemirrorJson: any = getProsemirrorContent(page.content);
let prosemirrorJson: any;
if (singlePage) {
prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
);
} else {
// mentions is already turned to links during the zip process
prosemirrorJson = getProsemirrorContent(page.content);
}
if (page.title) {
prosemirrorJson.content.unshift(titleNode);
@ -115,7 +131,8 @@ export class ExportService {
'pages.title',
'pages.content',
'pages.parentPageId',
'pages.spaceId'
'pages.spaceId',
'pages.workspaceId',
])
.where('spaceId', '=', spaceId)
.execute();
@ -160,7 +177,10 @@ export class ExportService {
for (const page of children) {
const childPages = tree[page.id] || [];
const prosemirrorJson = getProsemirrorContent(page.content);
const prosemirrorJson = await this.turnPageMentionsToLinks(
getProsemirrorContent(page.content),
page.workspaceId,
);
const currentPagePath = slugIdToPath[page.slugId];
@ -219,4 +239,107 @@ export class ExportService {
);
}
}
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
const doc = jsonToNode(prosemirrorJson);
const pageMentionIds = [];
doc.descendants((node: Node) => {
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
if (node.attrs.entityId) {
pageMentionIds.push(node.attrs.entityId);
}
}
});
if (pageMentionIds.length < 1) {
return prosemirrorJson;
}
const pages = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'creatorId',
'spaceId',
'workspaceId',
])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)
.execute();
const pageMap = new Map(pages.map((page) => [page.id, page]));
let editorState = EditorState.create({
doc: doc,
});
const transaction = editorState.tr;
let offset = 0;
/**
* Helper function to replace a mention node with a link node.
*/
const replaceMentionWithLink = (
node: Node,
pos: number,
title: string,
slugId: string,
spaceSlug: string,
) => {
const linkTitle = title || 'untitled';
const truncatedTitle = linkTitle?.substring(0, 70);
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
// Create the link URL
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
// Create a link mark and a text node with that mark
const linkMark = editorState.schema.marks.link.create({ href: link });
const linkTextNode = editorState.schema.text(linkTitle, [linkMark]);
// Calculate positions (adjusted by the current offset)
const from = pos + offset;
const to = pos + offset + node.nodeSize;
// Replace the node in the transaction and update the offset
transaction.replaceWith(from, to, linkTextNode);
offset += linkTextNode.nodeSize - node.nodeSize;
};
// find and convert page mentions to links
editorState.doc.descendants((node: Node, pos: number) => {
// Check if the node is a page mention
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
const { entityId: pageId, slugId, label } = node.attrs;
const page = pageMap.get(pageId);
if (page) {
replaceMentionWithLink(
node,
pos,
page.title,
page.slugId,
page.space.slug,
);
} else {
// if page is not found, default to the node label and slugId
replaceMentionWithLink(node, pos, label, slugId, 'undefined');
}
}
});
if (transaction.docChanged) {
editorState = editorState.apply(transaction);
}
const updatedDoc = editorState.doc;
return updatedDoc.toJSON();
}
}

View File

@ -7,6 +7,9 @@ import { Page } from '@docmost/db/types/entity.types';
export type PageExportTree = Record<string, Page[]>;
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
export function getExportExtension(format: string) {
if (format === ExportFormat.HTML) {
return '.html';
@ -83,13 +86,11 @@ export function replaceInternalLinks(
currentPagePath: string,
) {
const doc = jsonToNode(prosemirrorJson);
const internalLinkRegex =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
doc.descendants((node: Node) => {
for (const mark of node.marks) {
if (mark.type.name === 'link' && mark.attrs.href) {
const match = mark.attrs.href.match(internalLinkRegex);
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
if (match) {
const markLink = mark.attrs.href;

View File

@ -1,10 +1,16 @@
export enum QueueName {
EMAIL_QUEUE = '{email-queue}',
ATTACHEMENT_QUEUE = '{attachment-queue}',
ATTACHMENT_QUEUE = '{attachment-queue}',
GENERAL_QUEUE = '{general-queue}',
}
export enum QueueJob {
SEND_EMAIL = 'send-email',
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update',
PAGE_BACKLINKS = 'page-backlinks',
}

View File

@ -0,0 +1,8 @@
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
export interface IPageBacklinkJob {
pageId: string;
workspaceId: string;
mentions: MentionNode[];
}

View File

@ -0,0 +1,129 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../constants';
import { IPageBacklinkJob } from '../constants/queue.interface';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { executeTx } from '@docmost/db/utils';
@Processor(QueueName.GENERAL_QUEUE)
export class BacklinksProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(BacklinksProcessor.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly backlinkRepo: BacklinkRepo,
) {
super();
}
async process(job: Job<IPageBacklinkJob, void>): Promise<void> {
try {
const { pageId, mentions, workspaceId } = job.data;
switch (job.name) {
case QueueJob.PAGE_BACKLINKS:
{
await executeTx(this.db, async (trx) => {
const existingBacklinks = await trx
.selectFrom('backlinks')
.select('targetPageId')
.where('sourcePageId', '=', pageId)
.execute();
if (existingBacklinks.length === 0 && mentions.length === 0) {
return;
}
const existingTargetPageIds = existingBacklinks.map(
(backlink) => backlink.targetPageId,
);
const targetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
// make sure target pages belong to the same workspace
let validTargetPages = [];
if (targetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
.where('id', 'in', targetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
const validTargetPageIds = validTargetPages.map(
(page) => page.id,
);
// new backlinks
const backlinksToAdd = validTargetPageIds.filter(
(id) => !existingTargetPageIds.includes(id),
);
// stale backlinks
const backlinksToRemove = existingTargetPageIds.filter(
(existingId) => !validTargetPageIds.includes(existingId),
);
// add new backlinks
if (backlinksToAdd.length > 0) {
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
sourcePageId: pageId,
targetPageId: targetPageId,
workspaceId: workspaceId,
}));
await this.backlinkRepo.insertBacklink(newBacklinks, trx);
this.logger.debug(
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
);
}
// remove stale backlinks
if (backlinksToRemove.length > 0) {
await this.db
.deleteFrom('backlinks')
.where('sourcePageId', '=', pageId)
.where('targetPageId', 'in', backlinksToRemove)
.execute();
this.logger.debug(
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
);
}
});
}
break;
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}

View File

@ -3,6 +3,7 @@ import { BullModule } from '@nestjs/bullmq';
import { EnvironmentService } from '../environment/environment.service';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
import { QueueName } from './constants';
import { BacklinksProcessor } from "./processors/backlinks.processor";
@Global()
@Module({
@ -33,9 +34,13 @@ import { QueueName } from './constants';
name: QueueName.EMAIL_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.ATTACHEMENT_QUEUE,
name: QueueName.ATTACHMENT_QUEUE,
}),
BullModule.registerQueue({
name: QueueName.GENERAL_QUEUE,
}),
],
exports: [BullModule],
providers: [BacklinksProcessor]
})
export class QueueModule {}

View File

@ -23,7 +23,7 @@
"@hocuspocus/transformer": "^2.14.0",
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "^2.2.1",
"@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "^2.10.3",
"@tiptap/extension-code-block": "^2.10.3",
"@tiptap/extension-code-block-lowlight": "^2.10.3",

View File

@ -11,8 +11,9 @@ export * from "./lib/media-utils";
export * from "./lib/link";
export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/custom-code-block"
export * from "./lib/custom-code-block";
export * from "./lib/drawio";
export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";

View File

@ -0,0 +1,334 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
export interface MentionNodeAttrs {
/**
* unique mention node id (uuidv7)
*/
id: string | null;
/**
* The label to be rendered by the editor as the displayed text for this mentioned
* item, if provided.
*/
label?: string | null;
/**
* the entity type - user or page
*/
entityType: "user" | "page";
/**
* the entity id - userId or pageId
*/
entityId?: string | null;
/**
* page slugId
*/
slugId?: string | null;
/**
* the id of the user who initiated the mention
*/
creatorId?: string;
}
export type MentionOptions<
SuggestionItem = any,
Attrs extends Record<string, any> = MentionNodeAttrs,
> = {
/**
* The HTML attributes for a mention node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>;
/**
* A function to render the text of a mention.
* @param props The render props
* @returns The text
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
*/
renderText: (props: {
options: MentionOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => string;
/**
* A function to render the HTML of a mention.
* @param props The render props
* @returns The HTML as a ProseMirror DOM Output Spec
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
*/
renderHTML: (props: {
options: MentionOptions<SuggestionItem, Attrs>;
node: ProseMirrorNode;
}) => DOMOutputSpec;
/**
* Whether to delete the trigger character with backspace.
* @default false
*/
deleteTriggerWithBackspace: boolean;
/**
* The suggestion options.
* @default {}
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
*/
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, "editor">;
};
/**
* The plugin key for the mention plugin.
* @default 'mention'
*/
export const MentionPluginKey = new PluginKey("mention");
/**
* This extension allows you to insert mentions into the editor.
* @see https://www.tiptap.dev/api/extensions/mention
*/
export const Mention = Node.create<MentionOptions>({
name: "mention",
priority: 101,
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
deleteTriggerWithBackspace: false,
renderHTML({ options, node }) {
const isUserMention = node.attrs.entityType === "user";
return [
"span",
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${isUserMention ? options.suggestion.char : ""}${node.attrs.label ?? node.attrs.entityId}`,
];
},
suggestion: {
char: "@",
pluginKey: MentionPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(" ");
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: "text",
text: " ",
},
])
.run();
// get reference to `window` object from editor element, to support cross-frame JS usage
editor.view.dom.ownerDocument.defaultView
?.getSelection()
?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const type = state.schema.nodes[this.name];
const allow = !!$from.parent.type.contentMatch.matchType(type);
return allow;
},
},
};
},
group: "inline",
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => {
if (!attributes.id) {
return {};
}
return {
"data-id": attributes.id,
};
},
},
label: {
default: null,
parseHTML: (element) => element.getAttribute("data-label"),
renderHTML: (attributes) => {
if (!attributes.label) {
return {};
}
return {
"data-label": attributes.label,
};
},
},
entityType: {
default: null,
parseHTML: (element) => element.getAttribute("data-entity-type"),
renderHTML: (attributes) => {
if (!attributes.entityType) {
return {};
}
return {
"data-entity-type": attributes.entityType,
};
},
},
entityId: {
default: null,
parseHTML: (element) => element.getAttribute("data-entity-id"),
renderHTML: (attributes) => {
if (!attributes.entityId) {
return {};
}
return {
"data-entity-id": attributes.entityId,
};
},
},
slugId: {
default: null,
parseHTML: (element) => element.getAttribute("data-slug-id"),
renderHTML: (attributes) => {
if (!attributes.slugId) {
return {};
}
return {
"data-slug-id": attributes.slugId,
};
},
},
creatorId: {
default: null,
parseHTML: (element) => element.getAttribute("data-creator-id"),
renderHTML: (attributes) => {
if (!attributes.creatorId) {
return {};
}
return {
"data-creator-id": attributes.creatorId,
};
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
const mergedOptions = { ...this.options };
mergedOptions.HTMLAttributes = mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
);
const html = this.options.renderHTML({
options: mergedOptions,
node,
});
if (typeof html === "string") {
return [
"span",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
html,
];
}
return html;
},
renderText({ node }) {
return this.options.renderText({
options: this.options,
node,
});
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false;
const { selection } = state;
const { empty, anchor } = selection;
if (!empty) {
return false;
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true;
tr.insertText(
this.options.deleteTriggerWithBackspace
? ""
: this.options.suggestion.char || "",
pos,
pos + node.nodeSize,
);
return false;
}
});
return isMention;
}),
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

38
pnpm-lock.yaml generated
View File

@ -38,8 +38,8 @@ importers:
specifier: ^1.0.56
version: 1.0.56
'@sindresorhus/slugify':
specifier: ^2.2.1
version: 2.2.1
specifier: 1.1.0
version: 1.1.0
'@tiptap/core':
specifier: ^2.10.3
version: 2.10.3(@tiptap/pm@2.10.3)
@ -3155,13 +3155,13 @@ packages:
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
'@sindresorhus/slugify@2.2.1':
resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==}
engines: {node: '>=12'}
'@sindresorhus/slugify@1.1.0':
resolution: {integrity: sha512-ujZRbmmizX26yS/HnB3P9QNlNa4+UvHh+rIse3RbOXLp8yl6n1TxB4t7NHggtVgS8QmmOtzXo48kCxZGACpkPw==}
engines: {node: '>=10'}
'@sindresorhus/transliterate@1.6.0':
resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==}
engines: {node: '>=12'}
'@sindresorhus/transliterate@0.1.2':
resolution: {integrity: sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==}
engines: {node: '>=10'}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
@ -5327,10 +5327,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-prettier@9.1.0:
resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
hasBin: true
@ -6507,6 +6503,9 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.deburr@4.1.0:
resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@ -11847,14 +11846,15 @@ snapshots:
'@sinclair/typebox@0.27.8': {}
'@sindresorhus/slugify@2.2.1':
'@sindresorhus/slugify@1.1.0':
dependencies:
'@sindresorhus/transliterate': 1.6.0
escape-string-regexp: 5.0.0
'@sindresorhus/transliterate': 0.1.2
escape-string-regexp: 4.0.0
'@sindresorhus/transliterate@1.6.0':
'@sindresorhus/transliterate@0.1.2':
dependencies:
escape-string-regexp: 5.0.0
escape-string-regexp: 2.0.0
lodash.deburr: 4.1.0
'@sinonjs/commons@3.0.1':
dependencies:
@ -14484,8 +14484,6 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-prettier@9.1.0(eslint@9.15.0(jiti@1.21.0)):
dependencies:
eslint: 9.15.0(jiti@1.21.0)
@ -15984,6 +15982,8 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.deburr@4.1.0: {}
lodash.defaults@4.2.0: {}
lodash.flatten@4.4.0: {}