mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
+31
-1
@@ -2,12 +2,42 @@ import type { EditorView } from "@tiptap/pm/view";
|
|||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
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,
|
view: EditorView,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
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) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const [file] = Array.from(event.clipboardData.files);
|
const [file] = Array.from(event.clipboardData.files);
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "./link.module.css";
|
||||||
|
|
||||||
export type LinkPreviewPanelProps = {
|
export type LinkPreviewPanelProps = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -31,12 +32,7 @@ export const LinkPreviewPanel = ({
|
|||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
inherit
|
className={classes.link}
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -64,8 +65,11 @@ import clojure from "highlight.js/lib/languages/clojure";
|
|||||||
import fortran from "highlight.js/lib/languages/fortran";
|
import fortran from "highlight.js/lib/languages/fortran";
|
||||||
import haskell from "highlight.js/lib/languages/haskell";
|
import haskell from "highlight.js/lib/languages/haskell";
|
||||||
import scala from "highlight.js/lib/languages/scala";
|
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 { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||||
import i18n from "i18next";
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common);
|
const lowlight = createLowlight(common);
|
||||||
lowlight.register("mermaid", plaintext);
|
lowlight.register("mermaid", plaintext);
|
||||||
@@ -133,6 +137,23 @@ export const mainExtensions = [
|
|||||||
class: "comment-mark",
|
class: "comment-mark",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Mention.configure({
|
||||||
|
suggestion: {
|
||||||
|
allowSpaces: true,
|
||||||
|
items: () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
render: mentionRenderItems,
|
||||||
|
},
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mention",
|
||||||
|
},
|
||||||
|
}).extend({
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
}),
|
||||||
Table.configure({
|
Table.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: false,
|
lastColumnResizable: false,
|
||||||
|
|||||||
@@ -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 VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handleFilePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||||
import DrawioMenu from "./components/drawio/drawio-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) =>
|
handleDrop: (view, event, _slice, moved) =>
|
||||||
handleFileDrop(view, event, moved, pageId),
|
handleFileDrop(view, event, moved, pageId),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,8 +56,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: light-dark(#207af1, #587da9);
|
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
|
||||||
/*font-weight: bold;*/
|
@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;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,5 @@
|
|||||||
@import "./media.css";
|
@import "./media.css";
|
||||||
@import "./code.css";
|
@import "./code.css";
|
||||||
@import "./print.css";
|
@import "./print.css";
|
||||||
|
@import "./mention.css";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.node-mention {
|
||||||
|
&.ProseMirror-selectednode {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery(
|
|||||||
params: SearchSuggestionParams,
|
params: SearchSuggestionParams,
|
||||||
): UseQueryResult<ISuggestionResult, Error> {
|
): UseQueryResult<ISuggestionResult, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["search-suggestion", params],
|
queryKey: ["search-suggestion", params.query],
|
||||||
|
staleTime: 60 * 1000, // 1min
|
||||||
queryFn: () => searchSuggestions(params),
|
queryFn: () => searchSuggestions(params),
|
||||||
enabled: !!params.query,
|
enabled: !!params.query,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Group, Center, Text } from "@mantine/core";
|
import { Group, Center, Text } from "@mantine/core";
|
||||||
import { Spotlight } from "@mantine/spotlight";
|
import { Spotlight } from "@mantine/spotlight";
|
||||||
import { IconFileDescription, IconSearch } from "@tabler/icons-react";
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
import { usePageSearchQuery } from "@/features/search/queries/search-query";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
import { getPageIcon } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SearchSpotlightProps {
|
interface SearchSpotlightProps {
|
||||||
@@ -33,13 +34,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Group wrap="nowrap" w="100%">
|
<Group wrap="nowrap" w="100%">
|
||||||
<Center>
|
<Center>{getPageIcon(page?.icon)}</Center>
|
||||||
{page?.icon ? (
|
|
||||||
<span style={{ fontSize: "20px" }}>{page.icon}</span>
|
|
||||||
) : (
|
|
||||||
<IconFileDescription size={20} />
|
|
||||||
)}
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text>{page.title}</Text>
|
<Text>{page.title}</Text>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface IPageSearch {
|
export interface IPageSearch {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,11 +21,15 @@ export interface SearchSuggestionParams {
|
|||||||
query: string;
|
query: string;
|
||||||
includeUsers?: boolean;
|
includeUsers?: boolean;
|
||||||
includeGroups?: boolean;
|
includeGroups?: boolean;
|
||||||
|
includePages?: boolean;
|
||||||
|
spaceId?: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISuggestionResult {
|
export interface ISuggestionResult {
|
||||||
users?: Partial<IUser[]>;
|
users?: Partial<IUser[]>;
|
||||||
groups?: Partial<IGroup[]>;
|
groups?: Partial<IGroup[]>;
|
||||||
|
pages?: Partial<IPage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageSearchParams {
|
export interface IPageSearchParams {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const INTERNAL_LINK_REGEX =
|
||||||
|
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||||
@@ -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";
|
import { TFunction } from "i18next";
|
||||||
|
|
||||||
export function formatMemberCount(memberCount: number, t: TFunction): string {
|
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 {
|
export function extractPageSlugId(slug: string): string {
|
||||||
if (!input) {
|
if (!slug) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const parts = input.split("-");
|
if (isValidUUID(slug)) {
|
||||||
return parts.length > 1 ? parts[parts.length - 1] : input;
|
return slug;
|
||||||
|
}
|
||||||
|
const parts = slug.split("-");
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1] : slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const computeSpaceSlug = (name: string) => {
|
export const computeSpaceSlug = (name: string) => {
|
||||||
@@ -76,3 +83,13 @@ export function decodeBase64ToSvgString(base64Data: string): string {
|
|||||||
export function capitalizeFirstChar(string: string) {
|
export function capitalizeFirstChar(string: string) {
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export default function Page() {
|
|||||||
data: page,
|
data: page,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
error,
|
||||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !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>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||||
@@ -75,6 +76,7 @@ export const tiptapExtensions = [
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
Mention
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
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()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
@@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
this.logger.warn('jsonToText' + err?.['message']);
|
this.logger.warn('jsonToText' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let page: Page = null;
|
||||||
let page = null;
|
|
||||||
|
|
||||||
|
try {
|
||||||
await executeTx(this.db, async (trx) => {
|
await executeTx(this.db, async (trx) => {
|
||||||
page = await this.pageRepo.findById(pageId, {
|
page = await this.pageRepo.findById(pageId, {
|
||||||
withLock: true,
|
withLock: true,
|
||||||
|
includeContent: true,
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||||
|
page = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.pageRepo.updatePage(
|
await this.pageRepo.updatePage(
|
||||||
{
|
{
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
@@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension {
|
|||||||
pageId,
|
pageId,
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
this.eventEmitter.emit('collab.page.updated', {
|
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||||
page: {
|
|
||||||
...page,
|
|
||||||
lastUpdatedById: context.user.id,
|
|
||||||
content: tiptapJson,
|
|
||||||
textContent: textContent,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to update page ${pageId}`, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
|
|||||||
// extract db value if present
|
// extract db value if present
|
||||||
if (pathname.length > 1) {
|
if (pathname.length > 1) {
|
||||||
const value = pathname.slice(1);
|
const value = pathname.slice(1);
|
||||||
if (!isNaN(parseInt(value))){
|
if (!isNaN(parseInt(value))) {
|
||||||
db = parseInt(value, 10);
|
db = parseInt(value, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,3 +44,12 @@ export function createRetryStrategy() {
|
|||||||
return Math.max(Math.min(Math.exp(times), 20000), 3000);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service';
|
|||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { Space } from '@docmost/db/types/entity.types';
|
import { Space } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@Processor(QueueName.ATTACHEMENT_QUEUE)
|
@Processor(QueueName.ATTACHMENT_QUEUE)
|
||||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||||
constructor(private readonly attachmentService: AttachmentService) {
|
constructor(private readonly attachmentService: AttachmentService) {
|
||||||
|
|||||||
@@ -33,9 +33,21 @@ export class SearchSuggestionDTO {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeUsers?: string;
|
includeUsers?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeGroups?: number;
|
includeGroups?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includePages?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
spaceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,13 @@ export class SearchController {
|
|||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('suggest')
|
@Post('suggest')
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
@Body() dto: SearchSuggestionDTO,
|
@Body() dto: SearchSuggestionDTO,
|
||||||
|
@AuthUser() user: User,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.searchService.searchSuggestions(dto, workspace.id);
|
return this.searchService.searchSuggestions(dto, user.id, workspace.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const tsquery = require('pg-tsquery')();
|
const tsquery = require('pg-tsquery')();
|
||||||
@@ -14,6 +15,7 @@ export class SearchService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchPage(
|
async searchPage(
|
||||||
@@ -29,15 +31,15 @@ export class SearchService {
|
|||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
'id',
|
'id',
|
||||||
|
'slugId',
|
||||||
'title',
|
'title',
|
||||||
'icon',
|
'icon',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'slugId',
|
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
sql<number>`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'),
|
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',
|
'highlight',
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -66,35 +68,59 @@ export class SearchService {
|
|||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
suggestion: SearchSuggestionDTO,
|
suggestion: SearchSuggestionDTO,
|
||||||
|
userId: string,
|
||||||
workspaceId: 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 users = [];
|
||||||
let groups = [];
|
let groups = [];
|
||||||
|
let pages = [];
|
||||||
|
|
||||||
|
const limit = suggestion?.limit || 10;
|
||||||
|
const query = suggestion.query.toLowerCase().trim();
|
||||||
|
|
||||||
if (suggestion.includeUsers) {
|
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) {
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SpaceService {
|
|||||||
private spaceRepo: SpaceRepo,
|
private spaceRepo: SpaceRepo,
|
||||||
private spaceMemberService: SpaceMemberService,
|
private spaceMemberService: SpaceMemberService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createSpace(
|
async createSpace(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|||||||
import * as process from 'node:process';
|
import * as process from 'node:process';
|
||||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
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
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||||
@@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
|
BacklinkRepo,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WorkspaceRepo,
|
WorkspaceRepo,
|
||||||
@@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
UserTokenRepo,
|
UserTokenRepo,
|
||||||
|
BacklinkRepo,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,7 +166,16 @@ export class PageRepo {
|
|||||||
.withRecursive('page_hierarchy', (db) =>
|
.withRecursive('page_hierarchy', (db) =>
|
||||||
db
|
db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId'])
|
.select([
|
||||||
|
'id',
|
||||||
|
'slugId',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'content',
|
||||||
|
'parentPageId',
|
||||||
|
'spaceId',
|
||||||
|
'workspaceId',
|
||||||
|
])
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
.unionAll((exp) =>
|
.unionAll((exp) =>
|
||||||
exp
|
exp
|
||||||
@@ -179,6 +188,7 @@ export class PageRepo {
|
|||||||
'p.content',
|
'p.content',
|
||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
|
'p.workspaceId',
|
||||||
])
|
])
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
|
||||||
),
|
),
|
||||||
|
|||||||
+16
-3
@@ -42,6 +42,15 @@ export interface Attachments {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Backlinks {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
sourcePageId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Comments {
|
export interface Comments {
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
@@ -51,6 +60,7 @@ export interface Comments {
|
|||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
parentCommentId: string | null;
|
parentCommentId: string | null;
|
||||||
|
resolvedAt: Timestamp | null;
|
||||||
selection: string | null;
|
selection: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -59,6 +69,7 @@ export interface Comments {
|
|||||||
export interface Groups {
|
export interface Groups {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
@@ -118,6 +129,7 @@ export interface Pages {
|
|||||||
export interface SpaceMembers {
|
export interface SpaceMembers {
|
||||||
addedById: string | null;
|
addedById: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -135,7 +147,7 @@ export interface Spaces {
|
|||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
slug: string | null;
|
slug: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
visibility: Generated<string>;
|
visibility: Generated<string>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -155,7 +167,7 @@ export interface Users {
|
|||||||
locale: string | null;
|
locale: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
role: string;
|
role: string | null;
|
||||||
settings: Json | null;
|
settings: Json | null;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
@@ -186,13 +198,13 @@ export interface WorkspaceInvitations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Workspaces {
|
export interface Workspaces {
|
||||||
allowedEmailDomains: Generated<string[] | null>;
|
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
customDomain: string | null;
|
customDomain: string | null;
|
||||||
defaultRole: Generated<string>;
|
defaultRole: Generated<string>;
|
||||||
defaultSpaceId: string | null;
|
defaultSpaceId: string | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
emailDomains: Generated<string[] | null>;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
@@ -203,6 +215,7 @@ export interface Workspaces {
|
|||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
|
backlinks: Backlinks;
|
||||||
comments: Comments;
|
comments: Comments;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SpaceMembers,
|
SpaceMembers,
|
||||||
WorkspaceInvitations,
|
WorkspaceInvitations,
|
||||||
UserTokens,
|
UserTokens,
|
||||||
|
Backlinks,
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@@ -76,4 +77,9 @@ export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;
|
|||||||
// User Token
|
// User Token
|
||||||
export type UserToken = Selectable<UserTokens>;
|
export type UserToken = Selectable<UserTokens>;
|
||||||
export type InsertableUserToken = Insertable<UserTokens>;
|
export type InsertableUserToken = Insertable<UserTokens>;
|
||||||
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;
|
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'>>;
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ export class ExportController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawContent = await this.exportService.exportPage(dto.format, page);
|
const rawContent = await this.exportService.exportPage(
|
||||||
|
dto.format,
|
||||||
|
page,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': getMimeType(fileExt),
|
'Content-Type': getMimeType(fileExt),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
||||||
import { turndown } from './turndown-utils';
|
import { turndown } from './turndown-utils';
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
updateAttachmentUrls,
|
updateAttachmentUrls,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
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()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -33,16 +38,27 @@ export class ExportService {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly storageService: StorageService,
|
private readonly storageService: StorageService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async exportPage(format: string, page: Page) {
|
async exportPage(format: string, page: Page, singlePage?: boolean) {
|
||||||
const titleNode = {
|
const titleNode = {
|
||||||
type: 'heading',
|
type: 'heading',
|
||||||
attrs: { level: 1 },
|
attrs: { level: 1 },
|
||||||
content: [{ type: 'text', text: getPageTitle(page.title) }],
|
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) {
|
if (page.title) {
|
||||||
prosemirrorJson.content.unshift(titleNode);
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
@@ -115,7 +131,8 @@ export class ExportService {
|
|||||||
'pages.title',
|
'pages.title',
|
||||||
'pages.content',
|
'pages.content',
|
||||||
'pages.parentPageId',
|
'pages.parentPageId',
|
||||||
'pages.spaceId'
|
'pages.spaceId',
|
||||||
|
'pages.workspaceId',
|
||||||
])
|
])
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -160,7 +177,10 @@ export class ExportService {
|
|||||||
for (const page of children) {
|
for (const page of children) {
|
||||||
const childPages = tree[page.id] || [];
|
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];
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { Page } from '@docmost/db/types/entity.types';
|
|||||||
|
|
||||||
export type PageExportTree = Record<string, Page[]>;
|
export type PageExportTree = Record<string, Page[]>;
|
||||||
|
|
||||||
|
export const INTERNAL_LINK_REGEX =
|
||||||
|
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||||
|
|
||||||
export function getExportExtension(format: string) {
|
export function getExportExtension(format: string) {
|
||||||
if (format === ExportFormat.HTML) {
|
if (format === ExportFormat.HTML) {
|
||||||
return '.html';
|
return '.html';
|
||||||
@@ -83,13 +86,11 @@ export function replaceInternalLinks(
|
|||||||
currentPagePath: string,
|
currentPagePath: string,
|
||||||
) {
|
) {
|
||||||
const doc = jsonToNode(prosemirrorJson);
|
const doc = jsonToNode(prosemirrorJson);
|
||||||
const internalLinkRegex =
|
|
||||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
|
||||||
|
|
||||||
doc.descendants((node: Node) => {
|
doc.descendants((node: Node) => {
|
||||||
for (const mark of node.marks) {
|
for (const mark of node.marks) {
|
||||||
if (mark.type.name === 'link' && mark.attrs.href) {
|
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) {
|
if (match) {
|
||||||
const markLink = mark.attrs.href;
|
const markLink = mark.attrs.href;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
export enum QueueName {
|
export enum QueueName {
|
||||||
EMAIL_QUEUE = '{email-queue}',
|
EMAIL_QUEUE = '{email-queue}',
|
||||||
ATTACHEMENT_QUEUE = '{attachment-queue}',
|
ATTACHMENT_QUEUE = '{attachment-queue}',
|
||||||
|
GENERAL_QUEUE = '{general-queue}',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
SEND_EMAIL = 'send-email',
|
SEND_EMAIL = 'send-email',
|
||||||
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
|
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
|
||||||
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
||||||
|
PAGE_CONTENT_UPDATE = 'page-content-update',
|
||||||
|
|
||||||
|
PAGE_BACKLINKS = 'page-backlinks',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
||||||
|
|
||||||
|
|
||||||
|
export interface IPageBacklinkJob {
|
||||||
|
pageId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
mentions: MentionNode[];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
||||||
import { QueueName } from './constants';
|
import { QueueName } from './constants';
|
||||||
|
import { BacklinksProcessor } from "./processors/backlinks.processor";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -33,9 +34,13 @@ import { QueueName } from './constants';
|
|||||||
name: QueueName.EMAIL_QUEUE,
|
name: QueueName.EMAIL_QUEUE,
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.ATTACHEMENT_QUEUE,
|
name: QueueName.ATTACHMENT_QUEUE,
|
||||||
|
}),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: QueueName.GENERAL_QUEUE,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
|
providers: [BacklinksProcessor]
|
||||||
})
|
})
|
||||||
export class QueueModule {}
|
export class QueueModule {}
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"@hocuspocus/transformer": "^2.14.0",
|
"@hocuspocus/transformer": "^2.14.0",
|
||||||
"@joplin/turndown": "^4.0.74",
|
"@joplin/turndown": "^4.0.74",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
"@tiptap/core": "^2.10.3",
|
"@tiptap/core": "^2.10.3",
|
||||||
"@tiptap/extension-code-block": "^2.10.3",
|
"@tiptap/extension-code-block": "^2.10.3",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.10.3",
|
"@tiptap/extension-code-block-lowlight": "^2.10.3",
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export * from "./lib/media-utils";
|
|||||||
export * from "./lib/link";
|
export * from "./lib/link";
|
||||||
export * from "./lib/selection";
|
export * from "./lib/selection";
|
||||||
export * from "./lib/attachment";
|
export * from "./lib/attachment";
|
||||||
export * from "./lib/custom-code-block"
|
export * from "./lib/custom-code-block";
|
||||||
export * from "./lib/drawio";
|
export * from "./lib/drawio";
|
||||||
export * from "./lib/excalidraw";
|
export * from "./lib/excalidraw";
|
||||||
export * from "./lib/embed";
|
export * from "./lib/embed";
|
||||||
|
export * from "./lib/mention";
|
||||||
export * from "./lib/markdown";
|
export * from "./lib/markdown";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+19
-19
@@ -38,8 +38,8 @@ importers:
|
|||||||
specifier: ^1.0.56
|
specifier: ^1.0.56
|
||||||
version: 1.0.56
|
version: 1.0.56
|
||||||
'@sindresorhus/slugify':
|
'@sindresorhus/slugify':
|
||||||
specifier: ^2.2.1
|
specifier: 1.1.0
|
||||||
version: 2.2.1
|
version: 1.1.0
|
||||||
'@tiptap/core':
|
'@tiptap/core':
|
||||||
specifier: ^2.10.3
|
specifier: ^2.10.3
|
||||||
version: 2.10.3(@tiptap/pm@2.10.3)
|
version: 2.10.3(@tiptap/pm@2.10.3)
|
||||||
@@ -3155,13 +3155,13 @@ packages:
|
|||||||
'@sinclair/typebox@0.27.8':
|
'@sinclair/typebox@0.27.8':
|
||||||
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
|
||||||
|
|
||||||
'@sindresorhus/slugify@2.2.1':
|
'@sindresorhus/slugify@1.1.0':
|
||||||
resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==}
|
resolution: {integrity: sha512-ujZRbmmizX26yS/HnB3P9QNlNa4+UvHh+rIse3RbOXLp8yl6n1TxB4t7NHggtVgS8QmmOtzXo48kCxZGACpkPw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@sindresorhus/transliterate@1.6.0':
|
'@sindresorhus/transliterate@0.1.2':
|
||||||
resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==}
|
resolution: {integrity: sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@sinonjs/commons@3.0.1':
|
'@sinonjs/commons@3.0.1':
|
||||||
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
|
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
|
||||||
@@ -5327,10 +5327,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
eslint-config-prettier@9.1.0:
|
||||||
resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
|
resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6507,6 +6503,9 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.deburr@4.1.0:
|
||||||
|
resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -11847,14 +11846,15 @@ snapshots:
|
|||||||
|
|
||||||
'@sinclair/typebox@0.27.8': {}
|
'@sinclair/typebox@0.27.8': {}
|
||||||
|
|
||||||
'@sindresorhus/slugify@2.2.1':
|
'@sindresorhus/slugify@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sindresorhus/transliterate': 1.6.0
|
'@sindresorhus/transliterate': 0.1.2
|
||||||
escape-string-regexp: 5.0.0
|
escape-string-regexp: 4.0.0
|
||||||
|
|
||||||
'@sindresorhus/transliterate@1.6.0':
|
'@sindresorhus/transliterate@0.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 5.0.0
|
escape-string-regexp: 2.0.0
|
||||||
|
lodash.deburr: 4.1.0
|
||||||
|
|
||||||
'@sinonjs/commons@3.0.1':
|
'@sinonjs/commons@3.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -14484,8 +14484,6 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
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)):
|
eslint-config-prettier@9.1.0(eslint@9.15.0(jiti@1.21.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.15.0(jiti@1.21.0)
|
eslint: 9.15.0(jiti@1.21.0)
|
||||||
@@ -15984,6 +15982,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.deburr@4.1.0: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.flatten@4.4.0: {}
|
lodash.flatten@4.4.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user