diff --git a/.env.example b/.env.example index 4a74a6b4..6d537708 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,10 @@ DRAWIO_URL= DISABLE_TELEMETRY=false # Enable debug logging in production (default: false) -DEBUG_MODE=false \ No newline at end of file +DEBUG_MODE=false + +# Log database queries +DEBUG_DB=false + +# Log http requests +LOG_HTTP=false diff --git a/apps/client/package.json b/apps/client/package.json index e9197ef9..751bfa43 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.24.1", + "version": "0.25.0-beta.1", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -24,7 +24,6 @@ "@mantine/spotlight": "^8.3.12", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", - "@tiptap/extension-character-count": "^2.27.1", "alfaaz": "^1.1.0", "axios": "^1.13.2", "clsx": "^2.1.1", @@ -54,7 +53,6 @@ "react-router-dom": "^7.12.0", "semver": "^7.7.3", "socket.io-client": "^4.8.3", - "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.18", "zod": "^3.25.76" }, diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 1763e428..93c6f265 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.", "Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.", "Confirm": "Bestätigen", + "Copy as Markdown": "Als Markdown kopieren", "Copy link": "Link kopieren", "Create": "Erstellen", "Create group": "Gruppe erstellen", @@ -234,7 +235,9 @@ "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Invite link": "Einladungslink", "Copy": "Kopieren", + "Copy to space": "In Raum kopieren", "Copied": "Kopiert", + "Duplicate": "Duplizieren", "Select a user": "Benutzer auswählen", "Select a group": "Gruppe auswählen", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", @@ -251,6 +254,7 @@ "Export failed:": "Export fehlgeschlagen:", "export error": "Exportfehler", "Export page": "Seite exportieren", + "Export successful": "Export erfolgreich", "Export space": "Bereich exportieren", "Export {{type}}": "Exportiere {{type}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", @@ -326,6 +330,8 @@ "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.", + "Uploading {{name}}": "Lade {{name}} hoch", + "Uploading file": "Datei wird hochgeladen", "Table": "Tabelle", "Insert a table.": "Tabelle einfügen.", "Insert collapsible block.": "Einklappbaren Block einfügen.", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 8cb33378..c0578d2b 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Choose your preferred interface language.", "Choose your preferred page width.": "Choose your preferred page width.", "Confirm": "Confirm", + "Copy as Markdown": "Copy as Markdown", "Copy link": "Copy link", "Create": "Create", "Create group": "Create group", @@ -253,6 +254,7 @@ "Export failed:": "Export failed:", "export error": "export error", "Export page": "Export page", + "Export successful": "Export successful", "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", "Upload any file from your device.": "Upload any file from your device.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Table", "Insert a table.": "Insert a table.", "Insert collapsible block.": "Insert collapsible block.", diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index f99e8541..af02c493 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.", "Choose your preferred page width.": "Elige el ancho de página que prefieras.", "Confirm": "Confirmar", + "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar enlace", "Create": "Crear", "Create group": "Crear grupo", @@ -253,6 +254,7 @@ "Export failed:": "Exportación fallida:", "export error": "error de exportación", "Export page": "Exportar página", + "Export successful": "Exportación exitosa", "Export space": "Exportar espacio", "Export {{type}}": "Exportar {{type}}", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.", + "Uploading {{name}}": "Subiendo {{name}}", + "Uploading file": "Subiendo archivo", "Table": "Tabla", "Insert a table.": "Insertar una tabla.", "Insert collapsible block.": "Insertar bloque desplegable.", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 5644d719..40a1e68a 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.", "Choose your preferred page width.": "Choisissez votre largeur de page préférée.", "Confirm": "Confirmer", + "Copy as Markdown": "Copier comme Markdown", "Copy link": "Copier le lien", "Create": "Créer", "Create group": "Créer groupe", @@ -253,6 +254,7 @@ "Export failed:": "Échec de l'exportation :", "export error": "exporter l'erreur", "Export page": "Exporter la page", + "Export successful": "Exportation réussie", "Export space": "Exporter l'espace", "Export {{type}}": "Exporter {{type}}", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.", + "Uploading {{name}}": "Téléchargement de {{name}}", + "Uploading file": "Téléchargement du fichier", "Table": "Tableau", "Insert a table.": "Insérez un tableau.", "Insert collapsible block.": "Insérer un bloc repliable.", diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 8d00f451..ff80df0f 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.", "Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.", "Confirm": "Conferma", + "Copy as Markdown": "Copia come Markdown", "Copy link": "Copia link", "Create": "Crea", "Create group": "Crea gruppo", @@ -253,6 +254,7 @@ "Export failed:": "Esportazione fallita:", "export error": "errore di esportazione", "Export page": "Esporta pagina", + "Export successful": "Esportazione riuscita", "Export space": "Esporta spazio", "Export {{type}}": "Esporta {{type}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.", + "Uploading {{name}}": "Caricamento di {{name}}", + "Uploading file": "Caricamento file", "Table": "Tabella", "Insert a table.": "Inserisci una tabella.", "Insert collapsible block.": "Inserisci blocco comprimibile.", diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 2c3cce5a..4d18e074 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください", "Confirm": "確認", + "Copy as Markdown": "Markdownとしてコピー", "Copy link": "リンクをコピー", "Create": "新規作成", "Create group": "グループを作成", @@ -253,6 +254,7 @@ "Export failed:": "エクスポートに失敗しました:", "export error": "エクスポートエラー", "Export page": "エクスポートページ", + "Export successful": "エクスポート成功", "Export space": "エクスポートスペース", "Export {{type}}": "{{type}}をエクスポート", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", @@ -328,6 +330,8 @@ "Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします", + "Uploading {{name}}": "{{name}} をアップロード中", + "Uploading file": "ファイルをアップロード中", "Table": "テーブル", "Insert a table.": "テーブルを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します", diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 6e1f5b24..d9b48b04 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Confirm": "확인", + "Copy as Markdown": "Markdown으로 복사", "Copy link": "링크 복사", "Create": "생성", "Create group": "팀 생성", @@ -253,6 +254,7 @@ "Export failed:": "내보내기 실패:", "export error": "내보내기 오류", "Export page": "페이지 내보내기", + "Export successful": "내보내기 성공", "Export space": "Space 내보내기", "Export {{type}}": "{{type}} 내보내기", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", @@ -328,6 +330,8 @@ "Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.", + "Uploading {{name}}": "{{name}} 업로드 중", + "Uploading file": "파일 업로드 중", "Table": "테이블", "Insert a table.": "테이블 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.", diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 7db6836d..a7923b98 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Kies uw gewenste interfacetaal.", "Choose your preferred page width.": "Kies uw gewenste paginabreedte.", "Confirm": "Bevestig", + "Copy as Markdown": "Kopiëren als Markdown", "Copy link": "Link kopiëren", "Create": "Aanmaken", "Create group": "Groep aanmaken", @@ -253,6 +254,7 @@ "Export failed:": "Exporteren mislukt:", "export error": "Exporteer fout", "Export page": "Exporteer pagina", + "Export successful": "Export succesvol", "Export space": "Exporteer ruimte", "Export {{type}}": "Exporteer {{type}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any file from your device.": "Upload een bestand vanaf uw apparaat.", + "Uploading {{name}}": "Uploaden {{name}}", + "Uploading file": "Bestand uploaden", "Table": "Tabel", "Insert a table.": "Voeg een tabel in.", "Insert collapsible block.": "Inklapbaar blok invoegen.", diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 5d11ec7a..30cc0b21 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Escolha o idioma da interface.", "Choose your preferred page width.": "Escolha a largura preferida da página.", "Confirm": "Confirmar", + "Copy as Markdown": "Copiar como Markdown", "Copy link": "Copiar link", "Create": "Criar", "Create group": "Criar grupo", @@ -253,6 +254,7 @@ "Export failed:": "Falha ao exportar:", "export error": "erro de exportação", "Export page": "Exportar página", + "Export successful": "Exportação bem-sucedida", "Export space": "Exportar espaço", "Export {{type}}": "Exportar para {{type}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.", + "Uploading {{name}}": "Enviando {{name}}", + "Uploading file": "Enviando arquivo", "Table": "Tabela", "Insert a table.": "Insira uma tabela.", "Insert collapsible block.": "Insira um bloco colapsável.", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f1a9cd85..88e1f701 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Confirm": "Подтвердить", + "Copy as Markdown": "Копировать как Markdown", "Copy link": "Копировать ссылку", "Create": "Создать", "Create group": "Создать группу", @@ -253,6 +254,7 @@ "Export failed:": "Экспортирование не удалось:", "export error": "ошибка экспорта", "Export page": "Экспорт страницы", + "Export successful": "Экспорт выполнен успешно", "Export space": "Экспорт пространства", "Export {{type}}": "Экспорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.", + "Uploading {{name}}": "Загрузка {{name}}", + "Uploading file": "Загрузка файла", "Table": "Таблица", "Insert a table.": "Вставить таблицу.", "Insert collapsible block.": "Вставить сворачиваемый блок.", diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 2fb44ad1..e5cdaa40 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Confirm": "Підтвердити", + "Copy as Markdown": "Скопіювати як Markdown", "Copy link": "Копіювати посилання", "Create": "Створити", "Create group": "Створити групу", @@ -253,6 +254,7 @@ "Export failed:": "Експортування не вдалося:", "export error": "помилка експорту", "Export page": "Експорт сторінки", + "Export successful": "Експорт виконано успішно", "Export space": "Експорт простору", "Export {{type}}": "Експорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", @@ -328,6 +330,8 @@ "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", + "Uploading {{name}}": "Завантаження {{name}}", + "Uploading file": "Завантаження файлу", "Table": "Таблиця", "Insert a table.": "Вставити таблицю.", "Insert collapsible block.": "Вставити блок, що згортається.", diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index d4b25deb..a5eb84f1 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -29,6 +29,7 @@ "Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred page width.": "选择您喜欢的页面宽度。", "Confirm": "确认", + "Copy as Markdown": "复制为Markdown", "Copy link": "复制链接", "Create": "创建", "Create group": "创建群组", @@ -253,6 +254,7 @@ "Export failed:": "导出失败:", "export error": "导出出错", "Export page": "导出页面", + "Export successful": "导出成功", "Export space": "导出空间", "Export {{type}}": "导出为 {{type}}", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", @@ -328,6 +330,8 @@ "Upload any image from your device.": "从设备上传任何图像", "Upload any video from your device.": "从设备上传任何视频", "Upload any file from your device.": "从设备上传任何文件", + "Uploading {{name}}": "正在上传{{name}}", + "Uploading file": "正在上传文件", "Table": "表格", "Insert a table.": "插入一个表格", "Insert collapsible block.": "插入一个折叠块", diff --git a/apps/client/src/components/common/export-modal.tsx b/apps/client/src/components/common/export-modal.tsx index 25f4d328..53de8246 100644 --- a/apps/client/src/components/common/export-modal.tsx +++ b/apps/client/src/components/common/export-modal.tsx @@ -30,9 +30,11 @@ export default function ExportModal({ const [format, setFormat] = useState(ExportFormat.Markdown); const [includeChildren, setIncludeChildren] = useState(false); const [includeAttachments, setIncludeAttachments] = useState(false); + const [isExporting, setIsExporting] = useState(false); const { t } = useTranslation(); const handleExport = async () => { + setIsExporting(true); try { if (type === "page") { await exportPage({ @@ -45,6 +47,9 @@ export default function ExportModal({ if (type === "space") { await exportSpace({ spaceId: id, format, includeAttachments }); } + notifications.show({ + message: t("Export successful"), + }); onClose(); } catch (err) { notifications.show({ @@ -52,6 +57,8 @@ export default function ExportModal({ color: "red", }); console.error("export error", err); + } finally { + setIsExporting(false); } }; @@ -136,7 +143,7 @@ export default function ExportModal({ - + diff --git a/apps/client/src/components/common/paginate.tsx b/apps/client/src/components/common/paginate.tsx index d8e8106f..721c2f43 100644 --- a/apps/client/src/components/common/paginate.tsx +++ b/apps/client/src/components/common/paginate.tsx @@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; export interface PagePaginationProps { - currentPage: number; hasPrevPage: boolean; hasNextPage: boolean; - onPageChange: (newPage: number) => void; + onPrev: () => void; + onNext: () => void; } export default function Paginate({ - currentPage, hasPrevPage, hasNextPage, - onPageChange, + onPrev, + onNext, }: PagePaginationProps) { const { t } = useTranslation(); @@ -25,7 +25,7 @@ export default function Paginate({ - diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index 60349ecc..89e0f64e 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown } from "@tabler/icons-react"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; @@ -21,7 +21,7 @@ export function SwitchSpace({ spaceIcon, }: SwitchSpaceProps) { const navigate = useNavigate(); - const [opened, { close, open, toggle }] = useDisclosure(false); + const [opened, { close, toggle }] = useDisclosure(false); const handleSelect = (value: string) => { if (value) { @@ -44,9 +44,9 @@ export function SwitchSpace({ variant="subtle" fullWidth justify="space-between" - rightSection={} + rightSection={opened ? : } color="gray" - onClick={open} + onClick={toggle} > ( (null); @@ -72,10 +73,10 @@ export default function SpaceList() { {data?.items.length > 0 && ( goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> )} diff --git a/apps/client/src/features/space/components/space-members.tsx b/apps/client/src/features/space/components/space-members.tsx index 9186daaf..eadea590 100644 --- a/apps/client/src/features/space/components/space-members.tsx +++ b/apps/client/src/features/space/components/space-members.tsx @@ -41,9 +41,9 @@ export default function SpaceMembersList({ readOnly, }: SpaceMembersProps) { const { t } = useTranslation(); - const { search, page, setPage, handleSearch } = usePaginateAndSearch(); + const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { data, isLoading } = useSpaceMembersQuery(spaceId, { - page, + cursor, limit: 100, query: search, }); @@ -206,10 +206,10 @@ export default function SpaceMembersList({ {data?.items.length > 0 && ( goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> )} diff --git a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx index acfae399..e38cb9bc 100644 --- a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx +++ b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx @@ -28,19 +28,19 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; interface AllSpacesListProps { spaces: any[]; onSearch: (query: string) => void; - page: number; hasPrevPage?: boolean; hasNextPage?: boolean; - onPageChange: (page: number) => void; + onNext: () => void; + onPrev: () => void; } export default function AllSpacesList({ spaces, onSearch, - page, hasPrevPage, hasNextPage, - onPageChange, + onNext, + onPrev, }: AllSpacesListProps) { const { t } = useTranslation(); const [settingsOpened, { open: openSettings, close: closeSettings }] = @@ -145,10 +145,10 @@ export default function AllSpacesList({ {spaces.length > 0 && ( )} diff --git a/apps/client/src/features/space/services/space-service.ts b/apps/client/src/features/space/services/space-service.ts index f9894099..fb6987ad 100644 --- a/apps/client/src/features/space/services/space-service.ts +++ b/apps/client/src/features/space/services/space-service.ts @@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise { .split("filename=")[1] .replace(/"/g, ""); - saveAs(req.data, decodeURIComponent(fileName)); + let decodedFileName = fileName; + try { + decodedFileName = decodeURIComponent(fileName); + } catch (err) { + // fallback to raw filename + } + + saveAs(req.data, decodedFileName); } diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 21561038..3bc5b941 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -45,8 +45,10 @@ export type MoveTreeNodeEvent = { payload: { id: string; parentId: string; + oldParentId: string | null; index: number; position: string; + pageData: Partial; }; }; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index 3aa95417..faa7139f 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types"; import { invalidateOnCreatePage, invalidateOnDeletePage, - invalidateOnMovePage, + updateCacheOnMovePage, invalidateOnUpdatePage, } from "../page/queries/page-query"; import { RQ_KEY } from "../comment/queries/comment-query"; @@ -41,7 +41,13 @@ export const useQuerySubscription = () => { invalidateOnCreatePage(data.payload.data); break; case "moveTreeNode": - invalidateOnMovePage(); + updateCacheOnMovePage( + data.spaceId, + data.payload.id, + data.payload.oldParentId, + data.payload.parentId, + data.payload.pageData, + ); break; case "deleteTreeNode": invalidateOnDeletePage(data.payload.node.id); diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx index 81d5437e..0a7b52dd 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-invites-table.tsx @@ -1,6 +1,6 @@ import { Group, Table, Avatar, Text, Alert } from "@mantine/core"; import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts"; -import React, { useState } from "react"; +import React from "react"; import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts"; import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx"; import { IconInfoCircle } from "@tabler/icons-react"; @@ -8,12 +8,13 @@ import { timeAgo } from "@/lib/time.ts"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; import Paginate from "@/components/common/paginate.tsx"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; export default function WorkspaceInvitesTable() { const { t } = useTranslation(); - const [page, setPage] = useState(1); + const { cursor, goNext, goPrev } = useCursorPaginate(); const { data, isLoading } = useWorkspaceInvitationsQuery({ - page, + cursor, limit: 100, }); const { isAdmin } = useUserRole(); @@ -65,10 +66,10 @@ export default function WorkspaceInvitesTable() { {data?.items.length > 0 && ( goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> )} diff --git a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx index 49b9bf97..dbbd4fdf 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-members-table.tsx @@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components export default function WorkspaceMembersTable() { const { t } = useTranslation(); - const { search, page, setPage, handleSearch } = usePaginateAndSearch(); + const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { data, isLoading } = useWorkspaceMembersQuery({ - page, + cursor, limit: 100, query: search, }); @@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() { {data?.items.length > 0 && ( goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> )} diff --git a/apps/client/src/hooks/use-cursor-paginate.tsx b/apps/client/src/hooks/use-cursor-paginate.tsx new file mode 100644 index 00000000..7efb9730 --- /dev/null +++ b/apps/client/src/hooks/use-cursor-paginate.tsx @@ -0,0 +1,28 @@ +import { useState, useCallback } from "react"; + +export function useCursorPaginate() { + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]); + + const goNext = useCallback((nextCursor: string | null | undefined) => { + if (nextCursor) { + setCursorStack((prev) => [...prev, cursor]); + setCursor(nextCursor); + } + }, [cursor]); + + const goPrev = useCallback(() => { + setCursorStack((prev) => { + const next = prev.slice(0, -1); + setCursor(prev[prev.length - 1]); + return next; + }); + }, []); + + const resetCursor = useCallback(() => { + setCursor(undefined); + setCursorStack([]); + }, []); + + return { cursor, goNext, goPrev, resetCursor }; +} diff --git a/apps/client/src/hooks/use-paginate-and-search.tsx b/apps/client/src/hooks/use-paginate-and-search.tsx index fbdb49b9..5b6f90a0 100644 --- a/apps/client/src/hooks/use-paginate-and-search.tsx +++ b/apps/client/src/hooks/use-paginate-and-search.tsx @@ -2,16 +2,33 @@ import { useState, useRef, useCallback } from "react"; export function usePaginateAndSearch(initialQuery: string = "") { const [search, setSearch] = useState(initialQuery); - const [page, setPage] = useState(1); + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]); const prevSearchRef = useRef(search); const handleSearch = useCallback((newQuery: string) => { if (prevSearchRef.current !== newQuery) { prevSearchRef.current = newQuery; setSearch(newQuery); - setPage(1); + setCursor(undefined); + setCursorStack([]); } }, []); - return { search, page, setPage, handleSearch }; + const goNext = useCallback((nextCursor: string | null | undefined) => { + if (nextCursor) { + setCursorStack((prev) => [...prev, cursor]); + setCursor(nextCursor); + } + }, [cursor]); + + const goPrev = useCallback(() => { + setCursorStack((prev) => { + const next = prev.slice(0, -1); + setCursor(prev[prev.length - 1]); + return next; + }); + }, []); + + return { search, cursor, goNext, goPrev, handleSearch }; } diff --git a/apps/client/src/lib/types.ts b/apps/client/src/lib/types.ts index 2a926d46..7b68cf86 100644 --- a/apps/client/src/lib/types.ts +++ b/apps/client/src/lib/types.ts @@ -1,6 +1,7 @@ export interface QueryParams { query?: string; - page?: number; + cursor?: string; + beforeCursor?: string; limit?: number; adminView?: boolean; } @@ -29,9 +30,10 @@ export interface ApiResponse { export type IPaginationMeta = { limit: number; - page: number; hasNextPage: boolean; hasPrevPage: boolean; + nextCursor: string | null; + prevCursor: string | null; }; export type IPagination = { items: T[]; diff --git a/apps/client/src/pages/spaces/spaces.tsx b/apps/client/src/pages/spaces/spaces.tsx index 30df05d4..5fc63929 100644 --- a/apps/client/src/pages/spaces/spaces.tsx +++ b/apps/client/src/pages/spaces/spaces.tsx @@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role"; export default function Spaces() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const { search, page, setPage, handleSearch } = usePaginateAndSearch(); + const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const { data, isLoading } = useGetSpacesQuery({ - page, + cursor, limit: 30, query: search, }); @@ -41,10 +41,10 @@ export default function Spaces() { goNext(data?.meta?.nextCursor)} + onPrev={goPrev} /> diff --git a/apps/server/package.json b/apps/server/package.json index 12d801a0..44b58556 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.24.1", + "version": "0.25.0-beta.1", "description": "", "author": "", "private": true, @@ -74,11 +74,15 @@ "jsonwebtoken": "^9.0.3", "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", + "kysely-postgres-js": "^3.0.0", "ldapts": "^7.4.0", + "lib0": "^0.2.117", "mammoth": "^1.11.0", "mime-types": "^2.1.35", + "msgpackr": "^1.11.8", "nanoid": "3.3.11", "nestjs-kysely": "^1.2.0", + "nestjs-pino": "^4.5.0", "nodemailer": "^7.0.12", "openid-client": "^5.7.1", "otpauth": "^9.4.1", @@ -86,9 +90,11 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdfjs-dist": "^5.4.394", - "pg": "^8.16.3", "pg-tsquery": "^8.4.2", "pgvector": "^0.2.1", + "postgres": "^3.4.8", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", "postmark": "^4.0.5", "react": "^18.3.1", "reflect-metadata": "^0.2.2", @@ -98,6 +104,7 @@ "socket.io": "^4.8.3", "stripe": "^17.5.0", "tmp-promise": "^3.0.3", + "tseep": "^1.3.1", "typesense": "^2.1.0", "ws": "^8.19.0", "yauzl": "^3.2.0" @@ -116,7 +123,6 @@ "@types/nodemailer": "^6.4.17", "@types/passport-google-oauth20": "^2.0.16", "@types/passport-jwt": "^4.0.1", - "@types/pg": "^8.11.11", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.14", "@types/yauzl": "^2.10.3", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 56691444..8036b849 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -18,6 +18,7 @@ import { SecurityModule } from './integrations/security/security.module'; import { TelemetryModule } from './integrations/telemetry/telemetry.module'; import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisConfigService } from './integrations/redis/redis-config.service'; +import { LoggerModule } from './common/logger/logger.module'; const enterpriseModules = []; try { @@ -35,6 +36,7 @@ try { @Module({ imports: [ + LoggerModule, CoreModule, DatabaseModule, EnvironmentModule, diff --git a/apps/server/src/collaboration/adapter/collab-ws.adapter.ts b/apps/server/src/collaboration/adapter/collab-ws.adapter.ts index 352fe01f..18685bf0 100644 --- a/apps/server/src/collaboration/adapter/collab-ws.adapter.ts +++ b/apps/server/src/collaboration/adapter/collab-ws.adapter.ts @@ -30,14 +30,22 @@ export class CollabWsAdapter { return this.wss; } - public destroy() { + public close() { try { - this.wss.clients.forEach((client) => { - client.terminate(); - }); this.wss.close(); } catch (err) { console.error(err); } } + + public destroy() { + try { + this.wss.close(); + this.wss.clients.forEach((client) => { + client.terminate(); + }); + } catch (err) { + console.error(err); + } + } } diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 3f894572..b296c520 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -1,10 +1,9 @@ -import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server'; +import { Hocuspocus } from '@hocuspocus/server'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; import { AuthenticationExtension } from './extensions/authentication.extension'; import { PersistenceExtension } from './extensions/persistence.extension'; import { Injectable } from '@nestjs/common'; -import { Redis } from '@hocuspocus/extension-redis'; import { EnvironmentService } from '../integrations/environment/environment.service'; import { createRetryStrategy, @@ -12,21 +11,41 @@ import { RedisConfig, } from '../common/helpers'; import { LoggerExtension } from './extensions/logger.extension'; +import { + RedisSyncExtension, + SerializedHTTPRequest, +} from './extensions/redis-sync'; +import { WsSocketWrapper } from './extensions/redis-sync/ws-socket-wrapper'; +import RedisClient from 'ioredis'; +import { pack, unpack } from 'msgpackr'; +import { nanoid } from 'nanoid'; +import * as os from 'node:os'; +import { CollabWsAdapter } from './adapter/collab-ws.adapter'; +import { + CollaborationHandler, + CollabEventHandlers, +} from './collaboration.handler'; @Injectable() export class CollaborationGateway { - private hocuspocus: Hocuspocus; + private readonly hocuspocus: Hocuspocus; private redisConfig: RedisConfig; + // @ts-ignore + private readonly redisSync: RedisSyncExtension | null = + null; + private readonly withRedis: boolean; constructor( private authenticationExtension: AuthenticationExtension, private persistenceExtension: PersistenceExtension, private loggerExtension: LoggerExtension, private environmentService: EnvironmentService, + private collabEventsService: CollaborationHandler, ) { this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); + this.withRedis = !this.environmentService.isCollabDisableRedis(); - this.hocuspocus = HocuspocusServer.configure({ + this.hocuspocus = new Hocuspocus({ debounce: 10000, maxDebounce: 45000, unloadImmediately: false, @@ -34,26 +53,80 @@ export class CollaborationGateway { this.authenticationExtension, this.persistenceExtension, this.loggerExtension, - ...(this.environmentService.isCollabDisableRedis() - ? [] - : [ - new Redis({ - host: this.redisConfig.host, - port: this.redisConfig.port, - options: { - password: this.redisConfig.password, - db: this.redisConfig.db, - family: this.redisConfig.family, - retryStrategy: createRetryStrategy(), - }, - }), - ]), ], }); + + if (this.withRedis) { + // @ts-ignore + this.redisSync = new RedisSyncExtension({ + redis: new RedisClient({ + host: this.redisConfig.host, + port: this.redisConfig.port, + password: this.redisConfig.password, + db: this.redisConfig.db, + family: this.redisConfig.family, + retryStrategy: createRetryStrategy(), + }), + serverId: `collab-${os?.hostname()}-${nanoid(10)}`, + prefix: 'collab', + pack, + unpack, + // @ts-ignore + customEvents: this.collabEventsService.getHandlers(this.hocuspocus), + }); + this.hocuspocus.configuration.extensions.push(this.redisSync); + // @ts-ignore + this.redisSync.onConfigure({ instance: this.hocuspocus }); + } + } + + private serializeRequest(request: IncomingMessage): SerializedHTTPRequest { + return { + method: request.method ?? 'GET', + url: request.url ?? '/', + headers: { + 'sec-websocket-key': request.headers['sec-websocket-key'] ?? '', + 'sec-websocket-protocol': + request.headers['sec-websocket-protocol'] ?? '', + }, + socket: { remoteAddress: request.socket?.remoteAddress ?? '' }, + }; } handleConnection(client: WebSocket, request: IncomingMessage): any { - this.hocuspocus.handleConnection(client, request); + if (this.redisSync) { + const serializedHTTPRequest = this.serializeRequest(request); + const socketId = serializedHTTPRequest.headers['sec-websocket-key']; + + // Create wrapper socket that only receives events via emit() + // This prevents double-handling since Hocuspocus won't listen to raw WebSocket events + const wrappedSocket = new WsSocketWrapper(client); + + // Route through RedisSync extension (this calls handleConnection internally) + this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest); + + // Forward raw WebSocket messages to the extension + client.on('message', (data: ArrayBuffer) => { + this.redisSync!.onSocketMessage( + wrappedSocket as any, + serializedHTTPRequest, + data, + ); + }); + + // Forward close events + client.on('close', (code: number, reason: Buffer) => { + this.redisSync!.onSocketClose(socketId, code, reason); + }); + + // Forward pong events for keepalive + client.on('pong', (data: Buffer) => { + wrappedSocket.emit('pong', data); + }); + } else { + // Fallback to direct Hocuspocus connection + this.hocuspocus.handleConnection(client, request); + } } getConnectionCount() { @@ -64,7 +137,52 @@ export class CollaborationGateway { return this.hocuspocus.getDocumentsCount(); } - async destroy(): Promise { - await this.hocuspocus.destroy(); + handleYjsEvent( + eventName: TName, + documentName: string, + payload: Parameters[1], + ) { + return this.redisSync?.handleEvent(eventName, documentName, payload); + } + + openDirectConnection(documentName: string, context?: any) { + return this.hocuspocus.openDirectConnection(documentName, context); + } + + /* + *Can be used before calling openDirectConnection directly + */ + async lockDocument(documentName: string) { + return this.redisSync.lockDocument(documentName); + } + + /* + *Releases a document lock and stops the interval that maintains it. + */ + async releaseLock(documentName: string) { + return this.redisSync.releaseLock(documentName); + } + + async destroy(collabWsAdapter: CollabWsAdapter): Promise { + // eslint-disable-next-line no-async-promise-executor + await new Promise(async (resolve) => { + try { + // Wait for all documents to unload + this.hocuspocus.configuration.extensions.push({ + async afterUnloadDocument({ instance }) { + if (instance.getDocumentsCount() === 0) resolve(''); + }, + }); + + collabWsAdapter?.close(); + + if (this.hocuspocus.getDocumentsCount() === 0) resolve(''); + this.hocuspocus.closeConnections(); + } catch (error) { + console.error(error); + } + }); + + await this.hocuspocus.hooks('onDestroy', { instance: this.hocuspocus }); } } diff --git a/apps/server/src/collaboration/collaboration.handler.ts b/apps/server/src/collaboration/collaboration.handler.ts new file mode 100644 index 00000000..ec746550 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.handler.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Hocuspocus, Document } from '@hocuspocus/server'; + +export type CollabEventHandlers = ReturnType< + CollaborationHandler['getHandlers'] +>; + +@Injectable() +export class CollaborationHandler { + private readonly logger = new Logger(CollaborationHandler.name); + + constructor() {} + + getHandlers(hocuspocus: Hocuspocus) { + return { + alterState: async (documentName: string, payload: { pageId: string }) => { + // dummy + // this.logger.log('Processing', documentName, payload); + // await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => { + // const fragment = doc.getXmlFragment('default'); + //}); + }, + }; + } + + async withYdocConnection( + hocuspocus: Hocuspocus, + documentName: string, + context: any = {}, + fn: (doc: Document) => void, + ): Promise { + const connection = await hocuspocus.openDirectConnection( + documentName, + context, + ); + try { + await connection.transact(fn); + } finally { + await connection.disconnect(); + } + } +} diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 30cb0ccf..e9374c53 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -9,6 +9,7 @@ import { WebSocket } from 'ws'; import { TokenModule } from '../core/auth/token.module'; import { HistoryListener } from './listeners/history.listener'; import { LoggerExtension } from './extensions/logger.extension'; +import { CollaborationHandler } from './collaboration.handler'; @Module({ providers: [ @@ -17,6 +18,7 @@ import { LoggerExtension } from './extensions/logger.extension'; PersistenceExtension, LoggerExtension, HistoryListener, + CollaborationHandler, ], exports: [CollaborationGateway], imports: [TokenModule], @@ -46,16 +48,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy { }); wss.on('error', (error) => - this.logger.log('WebSocket server error:', error), + this.logger.error('WebSocket server error:', error), ); } async onModuleDestroy(): Promise { - if (this.collaborationGateway) { - await this.collaborationGateway.destroy(); - } - if (this.collabWsAdapter) { - this.collabWsAdapter.destroy(); - } + await this.collaborationGateway?.destroy(this.collabWsAdapter); + this.collabWsAdapter?.destroy(); } } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 06133c3f..afe1be08 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -1,14 +1,12 @@ import { StarterKit } from '@tiptap/starter-kit'; import { TextAlign } from '@tiptap/extension-text-align'; -import { TaskList } from '@tiptap/extension-task-list'; -import { TaskItem } from '@tiptap/extension-task-item'; -import { Underline } from '@tiptap/extension-underline'; import { Superscript } from '@tiptap/extension-superscript'; import SubScript from '@tiptap/extension-subscript'; import { Typography } from '@tiptap/extension-typography'; import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import { Youtube } from '@tiptap/extension-youtube'; +import { TaskList, TaskItem } from '@tiptap/extension-list'; import { Heading, Callout, @@ -42,11 +40,15 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // @tiptap/html library works best for generating prosemirror json state but not HTML // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 -import { Node } from '@tiptap/pm/model'; +//import { generateJSON } from '@tiptap/html'; +import { Node, Schema } from '@tiptap/pm/model'; +import { Logger } from '@nestjs/common'; export const tiptapExtensions = [ StarterKit.configure({ codeBlock: false, + link: false, + trailingNode: false, heading: false, }), Heading, @@ -59,7 +61,6 @@ export const tiptapExtensions = [ TaskItem.configure({ nested: true, }), - Underline, LinkExtension, Superscript, SubScript, @@ -110,9 +111,53 @@ export function jsonToText(tiptapJson: JSONContent) { } export function jsonToNode(tiptapJson: JSONContent) { - return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson); + const schema = getSchema(tiptapExtensions); + try { + return Node.fromJSON(schema, tiptapJson); + } catch (error) { + if ( + error instanceof RangeError && + error.message.includes('Unknown node type') + ) { + Logger.warn('Stripping unknown node types from document:', error.message); + const cleanedJson = stripUnknownNodes(tiptapJson, schema); + return Node.fromJSON(schema, cleanedJson); + } + throw error; + } } export function getPageId(documentName: string) { return documentName.split('.')[1]; } + +function stripUnknownNodes( + json: JSONContent, + schema: Schema, +): JSONContent | null { + if (!json || typeof json !== 'object') return json; + + // Recursively clean children first, flattening any unwrapped content + if (json.content && Array.isArray(json.content)) { + const newContent: JSONContent[] = []; + for (const child of json.content) { + const cleaned = stripUnknownNodes(child, schema); + if (Array.isArray(cleaned)) { + newContent.push(...cleaned); + } else if (cleaned) { + newContent.push(cleaned); + } + } + json.content = newContent; + } + + // Check if this node is unknown AFTER processing children + if (json.type && !schema.nodes[json.type]) { + // Unwrap: return cleaned children directly instead of wrapping + return ( + json.content && json.content.length > 0 ? json.content : null + ) as any; + } + + return json; +} diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 1a42bd97..04a360f7 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension { } if (userSpaceRole === SpaceRole.READER) { - data.connection.readOnly = true; + data.connectionConfig.readOnly = true; this.logger.debug(`User granted readonly access to page: ${pageId}`); } diff --git a/apps/server/src/collaboration/extensions/logger.extension.ts b/apps/server/src/collaboration/extensions/logger.extension.ts index 969fa712..bbca47bd 100644 --- a/apps/server/src/collaboration/extensions/logger.extension.ts +++ b/apps/server/src/collaboration/extensions/logger.extension.ts @@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common'; export class LoggerExtension implements Extension { private readonly logger = new Logger('Collab' + LoggerExtension.name); - async onDisconnect(data: onDisconnectPayload) { - this.logger.debug(`User disconnected from "${data.documentName}".`); - } - async afterUnloadDocument(data: onLoadDocumentPayload) { this.logger.debug('Unloaded ' + data.documentName + ' from memory'); } + + async onDisconnect(data: onDisconnectPayload) { + this.logger.debug('User disconnected from ' + data.documentName); + } } diff --git a/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts b/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts new file mode 100644 index 00000000..8ecfa00a --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/collab-proxy-socket.ts @@ -0,0 +1,70 @@ +import type RedisClient from 'ioredis'; +import { EventEmitter } from 'tseep'; +import type { + Pack, + RSAMessageClose, + RSAMessagePing, + RSAMessageSend, +} from './redis-sync.types'; + +export class CollabProxySocket extends EventEmitter { + private readonly replyTo: string; + private readonly serverChannel: string; + private readonly socketId: string; + private pub: RedisClient; + private readonly pack: Pack; + readyState = 1; + + constructor( + pub: RedisClient, + pack: Pack, + replyTo: string, + serverChannel: string, + socketId: string, + ) { + super(); + this.replyTo = replyTo; + this.socketId = socketId; + this.serverChannel = serverChannel; + this.pub = pub; + this.pack = pack; + this.once('close', () => { + this.readyState = 3; + }); + } + + private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) { + this.pub.publish(this.replyTo, this.pack(msg)); + } + + close(code?: number, reason?: string) { + if (this.readyState !== 1) return; + const msg: RSAMessageClose = { + type: 'close', + code, + reason, + socketId: this.socketId, + }; + this.publish(msg); + } + + ping() { + if (this.readyState !== 1) return; + const msg: RSAMessagePing = { + type: 'ping', + socketId: this.socketId, + replyTo: this.serverChannel, + }; + this.publish(msg); + } + + send(message: Uint8Array) { + if (this.readyState !== 1) return; + const msg: RSAMessageSend = { + type: 'send', + socketId: this.socketId, + message, + }; + this.publish(msg); + } +} diff --git a/apps/server/src/collaboration/extensions/redis-sync/index.ts b/apps/server/src/collaboration/extensions/redis-sync/index.ts new file mode 100644 index 00000000..a5847477 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/index.ts @@ -0,0 +1,2 @@ +export * from './redis-sync.extension'; +export type { SerializedHTTPRequest } from './redis-sync.extension'; diff --git a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts new file mode 100644 index 00000000..38747465 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.extension.ts @@ -0,0 +1,378 @@ +// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT +import { + Extension, + Hocuspocus, + IncomingMessage, + afterUnloadDocumentPayload, + onConfigurePayload, + onLoadDocumentPayload, +} from '@hocuspocus/server'; +import RedisClient from 'ioredis'; +import { readVarString } from 'lib0/decoding.js'; +import { CollabProxySocket } from './collab-proxy-socket'; +import { + BaseWebSocket, + Configuration, + CustomEvents, + Pack, + RSAMessage, + RSAMessageCloseProxy, + RSAMessageCustomEventComplete, + RSAMessageCustomEventStart, + RSAMessagePong, + RSAMessageProxy, + RSAMessageUnload, + SerializedHTTPRequest, + Unpack, +} from './redis-sync.types'; + +export type { Pack, SerializedHTTPRequest } from './redis-sync.types'; + +type ServerId = string; +type DocumentName = string; +type SocketId = string; + +export class RedisSyncExtension implements Extension { + priority = 1000; + private readonly pub: RedisClient; + private sub: RedisClient; + private readonly pack: Pack; + private readonly unpack: Unpack; + private originSockets: Record = {}; + private locks: Record = {}; + private lockPromises: Record> = {}; + private proxySockets: Record = {}; + private readonly prefix: string; + private readonly lockPrefix: string; + private readonly msgChannel: string; + private readonly serverId: ServerId; + private readonly customEventTTL: number; + private readonly lockTTL: number; + private instance!: Hocuspocus; + private readonly customEvents: TCE; + private replyIdCounter: number = 0; + // @ts-ignore + private pendingReplies: Record['resolve']> = + {}; + + constructor(configuration: Configuration) { + const { + redis, + pack, + unpack, + serverId, + lockTTL, + prefix, + customEvents, + customEventTTL, + } = configuration; + this.pub = redis.duplicate(); + this.sub = redis.duplicate(); + this.pack = pack; + this.unpack = unpack; + this.serverId = serverId; + this.lockTTL = lockTTL ?? 10_000; + this.customEventTTL = customEventTTL ?? 30_000; + this.prefix = prefix ?? 'collab'; + this.lockPrefix = `${this.prefix}Lock`; + this.msgChannel = `${this.prefix}Msg`; + this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents); + this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`); + this.sub.on('messageBuffer', this.handleRedisMessage); + this.pub.on('error', () => {}); + this.sub.on('error', () => {}); + } + private getKey(documentName: string) { + return `${this.lockPrefix}:${documentName}`; + } + + private closeProxy(socketId: string) { + const proxySocket = this.proxySockets[socketId]; + if (proxySocket) { + proxySocket.emit( + 'close', + 1000, + Buffer.from('provider_initiated', 'utf-8'), + ); + delete this.proxySockets[socketId]; + } + } + + private pongProxy(socketId: string) { + this.proxySockets[socketId]?.emit('pong'); + } + + private handleProxyMessage( + msg: Pick, + ) { + const { replyTo, message, serializedHTTPRequest } = msg; + const { headers } = serializedHTTPRequest; + const socketId = headers['sec-websocket-key']!; + let socket = this.proxySockets[socketId]; + if (!socket) { + socket = new CollabProxySocket( + this.pub, + this.pack, + replyTo, + `${this.msgChannel}:${this.serverId}`, + socketId, + ); + this.proxySockets[socketId] = socket; + this.instance.handleConnection( + socket as any, + serializedHTTPRequest as any, + {}, + ); + } + socket.emit('message', message); + } + + private getOrClaimLock(documentName: string) { + const lockPromise = this.pub.set( + this.getKey(documentName), + this.serverId, + 'PX', + this.lockTTL, + 'NX', + 'GET', + ); + this.lockPromises[documentName] = lockPromise; + // Briefly cache the serverId that claimed the doc to reduce load on redis + // When the claimant unloads the doc, it will send an unload message to immediately clear this + // a lockTTL / 2 guarantees stale reads < lockTTL upon server crash + setTimeout(() => { + delete this.lockPromises[documentName]; + }, this.lockTTL / 2); + return lockPromise; + } + + private getOrClaimLockThrottled(documentName: string) { + const existingWorkerIdPromise = this.lockPromises[documentName]; + if (existingWorkerIdPromise) return existingWorkerIdPromise; + return this.getOrClaimLock(documentName); + } + + private handleRedisMessage = async ( + _channel: Buffer, + packedMessage: Buffer, + ) => { + const msg = this.unpack(packedMessage) as RSAMessage; + const { type } = msg; + if (type === 'proxy') { + this.handleProxyMessage(msg); + return; + } + if (type === 'closeProxy') { + this.closeProxy(msg.socketId); + return; + } + if (type === 'pong') { + this.pongProxy(msg.socketId); + return; + } + if (type === 'unload') { + delete this.lockPromises[msg.documentName]; + return; + } + if (type === 'customEventStart') { + const { documentName, eventName, payload, replyTo, replyId } = msg; + const res = await this.handleEventLocally( + eventName as Extract, + documentName, + payload, + ); + const reply: RSAMessageCustomEventComplete = { + type: 'customEventComplete', + replyId, + payload: res, + }; + this.pub.publish(`${replyTo}`, this.pack(reply)); + return; + } + if (type === 'customEventComplete') { + const { replyId, payload } = msg; + const resolveFn = this.pendingReplies[replyId]; + if (!resolveFn) return; + delete this.pendingReplies[replyId]; + resolveFn(payload); + return; + } + const { socketId } = msg; + const socket = this.originSockets[socketId]; + if (!socket) { + // origin socket already cleaned up + return; + } + if (type === 'close') { + socket.close(msg.code, msg.reason); + } else if (type === 'ping') { + // Reply instantly to the proxy socket, without forwarding to client + // The origin socket handles heartbeat for itself + const { replyTo, socketId } = msg; + const reply: RSAMessagePong = { + type: 'pong', + socketId, + }; + this.pub.publish(`${replyTo}`, this.pack(reply)); + } else if (type === 'send') { + socket.send(msg.message); + } + }; + + async maintainLock(documentName: string) { + this.locks[documentName] = setInterval(() => { + this.pub.set( + this.getKey(documentName), + this.serverId, + 'PX', + this.lockTTL, + ); + }, this.lockTTL / 2); + } + + async releaseLock(documentName: string) { + clearInterval(this.locks[documentName]); + delete this.locks[documentName]; + return this.pub.del(this.getKey(documentName)); + } + + private async handleEventLocally>( + eventName: TName, + documentName: string, + payload: any, + ) { + const handler = this.customEvents[eventName]; + if (!handler) throw new Error(`Invalid eventName: ${eventName}`); + const result = await handler(documentName, payload); + return result as Promise>; + } + + async handleEvent>( + eventName: TName, + documentName: string, + payload: any, + ) { + const isDocLoadedOnInstance = this.instance.documents.has(documentName); + + if (isDocLoadedOnInstance) { + return this.handleEventLocally(eventName, documentName, payload); + } + + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + ++this.replyIdCounter; // bug in biome thinks this.replyIdCounter is not used if written on the line below + const replyId = this.replyIdCounter; + // another server owns the doc + const proxyMessage: RSAMessageCustomEventStart = { + eventName, + documentName, + payload, + replyTo: `${this.msgChannel}:${this.serverId}`, + replyId, + type: 'customEventStart', + }; + const msg = this.pack(proxyMessage); + this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg); + // @ts-ignore + const { promise, resolve, reject } = Promise.withResolvers(); + this.pendingReplies[replyId] = resolve; + setTimeout(() => { + reject('TIMEOUT'); + }, this.customEventTTL); + return promise as Promise>; + } + // This server owns the document, but hocuspocus hasn't loaded it yet + return this.handleEventLocally(eventName, documentName, payload); + } + + async lockDocument(documentName: string) { + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + throw new Error(`Could not lock document: ${documentName}`); + } + this.maintainLock(documentName); + return () => this.releaseLock(documentName); + } + + /* WebSocket Server Hooks */ + onSocketOpen( + ws: BaseWebSocket, + serializedHTTPRequest: SerializedHTTPRequest, + context = {}, + ) { + const socketId = serializedHTTPRequest.headers['sec-websocket-key']!; + this.originSockets[socketId] = ws; + this.instance.handleConnection( + ws as any, + serializedHTTPRequest as any, + context, + ); + } + + async onSocketMessage( + ws: BaseWebSocket, + serializedHTTPRequest: SerializedHTTPRequest, + detachableMsg: ArrayBuffer, + ) { + const message = new Uint8Array(detachableMsg.slice()); + const tmpMsg = new IncomingMessage(detachableMsg); + const documentName = readVarString(tmpMsg.decoder); + const isDocLoadedOnInstance = this.instance.documents.has(documentName); + + if (isDocLoadedOnInstance) { + ws.emit('message', message); + return; + } + + const proxyTo = await this.getOrClaimLockThrottled(documentName); + if (proxyTo && proxyTo !== this.serverId) { + // another server owns the doc + const proxyMessage: RSAMessageProxy = { + serializedHTTPRequest: serializedHTTPRequest, + replyTo: `${this.msgChannel}:${this.serverId}`, + message, + type: 'proxy', + }; + const msg = this.pack(proxyMessage); + this.pub.publish(`${this.msgChannel}:${proxyTo}`, msg); + return; + } + // This server owns the document, but hocuspocus hasn't loaded it yet + ws.emit('message', message); + } + + onSocketClose(socketId: string, code?: number, reason?: ArrayBuffer) { + const socket = this.originSockets[socketId]; + if (!socket) return; + // at this point the socket is considered GC'd and we cannot call close + // The origin socket did not set up any connections for the proxy, so none of the hooks will work if we just emit + socket?.emit('close', code, reason); + delete this.originSockets[socketId]; + const msg: RSAMessageCloseProxy = { type: 'closeProxy', socketId }; + this.pub.publish(this.msgChannel, this.pack(msg)).catch(() => {}); + } + + /* Hocuspocus hooks */ + async onConfigure({ instance }: onConfigurePayload) { + this.instance = instance; + } + + async onLoadDocument(data: onLoadDocumentPayload) { + const { documentName } = data; + // Refresh the lock TTL + this.maintainLock(documentName); + } + + async afterUnloadDocument(data: afterUnloadDocumentPayload) { + const { documentName } = data; + this.releaseLock(documentName); + // Broadcast to cluster to immediately remove the cached redis value + const msg: RSAMessageUnload = { type: 'unload', documentName }; + this.pub.publish(this.msgChannel, this.pack(msg)); + } + + async onDestroy() { + this.pub.disconnect(false); + this.sub.disconnect(false); + } +} diff --git a/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts new file mode 100644 index 00000000..1bbab80a --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/redis-sync.types.ts @@ -0,0 +1,121 @@ +import EventEmitter from 'node:events'; +import { IncomingHttpHeaders } from 'node:http2'; +import RedisClient from 'ioredis'; + +export type SecondParam = T extends ( + arg1: unknown, + arg2: infer A, + ...args: unknown[] +) => unknown + ? A + : never; + +export type SerializedHTTPRequest = { + method: string; + url: string; + headers: IncomingHttpHeaders; + socket: { remoteAddress: string }; +}; + +export type RSAMessageProxy = { + type: 'proxy'; + replyTo: string; + message: Uint8Array; + serializedHTTPRequest: SerializedHTTPRequest; +}; + +export type RSAMessageCloseProxy = { + type: 'closeProxy'; + socketId: string; +}; + +export type RSAMessageUnload = { + type: 'unload'; + documentName: string; +}; + +export type RSAMessageClose = { + type: 'close'; + code?: number; + reason?: string; + socketId: string; +}; + +export type RSAMessagePing = { + type: 'ping'; + socketId: string; + replyTo: string; +}; + +export type RSAMessagePong = { + type: 'pong'; + socketId: string; +}; + +export type RSAMessageSend = { + type: 'send'; + // @ts-ignore + message: Uint8Array; + socketId: string; +}; + +export type RSAMessageCustomEventStart = { + type: 'customEventStart'; + documentName: string; + eventName: TName; + payload: TPayload; + replyTo: string; + replyId: number; +}; + +export type RSAMessageCustomEventComplete = { + type: 'customEventComplete'; + replyId: number; + payload: unknown; +}; + +export type RSAMessage = + | RSAMessageProxy + | RSAMessageCloseProxy + | RSAMessageUnload + | RSAMessageClose + | RSAMessagePing + | RSAMessagePong + | RSAMessageSend + | RSAMessageCustomEventStart + | RSAMessageCustomEventComplete; + +// @ts-ignore +export type Pack = (msg: RSAMessage) => string | Buffer; + +export type Unpack = ( + // @ts-ignore + packedMessage: Uint8Array | Buffer, +) => RSAMessage; + +type ServerId = string; +type DocumentName = string; +type CustomEventName = string; + +export type CustomEvents = Record< + CustomEventName, + (documentName: string, payload: unknown) => Promise +>; + +export interface Configuration { + redis: RedisClient; + pack: Pack; + unpack: Unpack; + serverId: ServerId; + lockTTL?: number; + customEventTTL?: number; + prefix?: string; + customEvents?: TCE; +} + +export type BaseWebSocket = EventEmitter & { + readyState: number; + close(code?: number, reason?: string): void; + ping(): void; + send(message: Uint8Array): void; +}; diff --git a/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts b/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts new file mode 100644 index 00000000..258e6e12 --- /dev/null +++ b/apps/server/src/collaboration/extensions/redis-sync/ws-socket-wrapper.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'events'; +import type WebSocket from 'ws'; + +/** + * Wrapper around ws WebSocket that only receives events via emit(). + * This prevents double-handling when used with RedisSyncExtension. + */ +export class WsSocketWrapper extends EventEmitter { + private ws: WebSocket; + readyState = 1; + + constructor(ws: WebSocket) { + super(); + this.ws = ws; + this.once('close', () => { + this.readyState = 3; + }); + } + + close(code?: number, reason?: string) { + if (this.readyState !== 1) return; + this.readyState = 3; + try { + this.ws.close(code, reason); + } catch (e) { + // Socket already closed + } + } + + ping() { + if (this.readyState !== 1) return; + try { + this.ws.ping(); + } catch (e) { + // Socket already closed + } + } + + send(message: Uint8Array) { + if (this.readyState !== 1) return; + try { + this.ws.send(message); + } catch (e) { + // Socket already closed + } + } +} diff --git a/apps/server/src/collaboration/server/collab-app.module.ts b/apps/server/src/collaboration/server/collab-app.module.ts index 08a2f688..eb6b57fa 100644 --- a/apps/server/src/collaboration/server/collab-app.module.ts +++ b/apps/server/src/collaboration/server/collab-app.module.ts @@ -8,9 +8,11 @@ import { QueueModule } from '../../integrations/queue/queue.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { HealthModule } from '../../integrations/health/health.module'; import { CollaborationController } from './collaboration.controller'; +import { LoggerModule } from '../../common/logger/logger.module'; @Module({ imports: [ + LoggerModule, DatabaseModule, EnvironmentModule, CollaborationModule, diff --git a/apps/server/src/collaboration/server/collab-main.ts b/apps/server/src/collaboration/server/collab-main.ts index d71da428..1839c2e3 100644 --- a/apps/server/src/collaboration/server/collab-main.ts +++ b/apps/server/src/collaboration/server/collab-main.ts @@ -5,22 +5,27 @@ import { NestFastifyApplication, } from '@nestjs/platform-fastify'; import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor'; -import { InternalLogFilter } from '../../common/logger/internal-log-filter'; import { Logger } from '@nestjs/common'; +import { Logger as PinoLogger } from 'nestjs-pino'; async function bootstrap() { const app = await NestFactory.create( CollabAppModule, new FastifyAdapter({ - ignoreTrailingSlash: true, - ignoreDuplicateSlashes: true, - maxParamLength: 500, + routerOptions: { + maxParamLength: 1000, + ignoreTrailingSlash: true, + ignoreDuplicateSlashes: true, + }, }), { - logger: new InternalLogFilter(), + logger: false, + bufferLogs: false, }, ); + app.useLogger(app.get(PinoLogger)); + app.setGlobalPrefix('api', { exclude: ['/'] }); app.enableCors(); @@ -32,7 +37,8 @@ async function bootstrap() { const logger = new Logger('CollabServer'); const port = process.env.COLLAB_PORT || 3001; - await app.listen(port, '0.0.0.0', () => { + const host = process.env.HOST || '0.0.0.0'; + await app.listen(port, host, () => { logger.log(`Listening on http://127.0.0.1:${port}`); }); } diff --git a/apps/server/src/common/helpers/types/export-metadata.types.ts b/apps/server/src/common/helpers/types/export-metadata.types.ts new file mode 100644 index 00000000..f901c0e2 --- /dev/null +++ b/apps/server/src/common/helpers/types/export-metadata.types.ts @@ -0,0 +1,16 @@ +export type ExportPageMetadata = { + pageId: string; + slugId: string; + icon: string | null; + position: string; + parentPath: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ExportMetadata = { + exportedAt: string; + source: 'docmost'; + version: string; + pages: Record; +}; diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 738c455b..7c94bb48 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; import { sanitize } from 'sanitize-filename-ts'; import { FastifyRequest } from 'fastify'; +import { Readable, Transform } from 'stream'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); @@ -98,3 +99,38 @@ export function hasLicenseOrEE(opts: { const { licenseKey, plan, isCloud } = opts; return Boolean(licenseKey) || (isCloud && plan === 'business'); } + +/** + * Normalizes a database URL for postgres.js compatibility. + * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values + * - Removes `schema` parameter (has no effect via connection string) + * Note: If we don't strip them, the connection will fail + */ +export function normalizePostgresUrl(url: string): string { + const parsed = new URL(url); + const newParams = new URLSearchParams(); + + for (const [key, value] of parsed.searchParams) { + if (key === 'sslmode' && value === 'no-verify') continue; + if (key === 'schema') continue; + newParams.append(key, value); + } + + parsed.search = newParams.toString(); + return parsed.toString(); +} + +export function createByteCountingStream(source: Readable) { + let bytesRead = 0; + const stream = new Transform({ + transform(chunk, encoding, callback) { + bytesRead += chunk.length; + callback(null, chunk); + }, + }); + + source.pipe(stream); + source.on('error', (err) => stream.emit('error', err)); + + return { stream, getBytesRead: () => bytesRead }; +} diff --git a/apps/server/src/common/logger/logger.module.ts b/apps/server/src/common/logger/logger.module.ts new file mode 100644 index 00000000..327605a4 --- /dev/null +++ b/apps/server/src/common/logger/logger.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'; +import { createPinoConfig } from './pino.config'; + +@Module({ + imports: [PinoLoggerModule.forRoot(createPinoConfig())], + exports: [PinoLoggerModule], +}) +export class LoggerModule {} diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts new file mode 100644 index 00000000..7299a8e9 --- /dev/null +++ b/apps/server/src/common/logger/pino.config.ts @@ -0,0 +1,84 @@ +import { Params } from 'nestjs-pino'; +import { stdTimeFunctions } from 'pino'; + +const CONTEXTS_TO_IGNORE = [ + 'InstanceLoader', + 'RoutesResolver', + 'RouterExplorer', + 'LegacyRouteConverter', + 'WebSocketsController', +]; + +export function createPinoConfig(): Params { + const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production'; + const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true'; + const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true'; + + const level = isProduction && !isDebugMode ? 'info' : 'debug'; + + return { + pinoHttp: { + level, + timestamp: stdTimeFunctions.isoTime, + transport: !isProduction + ? { + target: 'pino-pretty', + options: { + colorize: true, + singleLine: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, + formatters: { + level: (label) => ({ level: label }), + }, + hooks: { + logMethod(inputArgs, method) { + if (isProduction && !isDebugMode) { + for (const arg of inputArgs) { + if (typeof arg === 'object' && arg !== null && 'context' in arg) { + const context = (arg as Record)['context']; + if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) { + return; + } + } + } + } + return method.apply(this, inputArgs); + }, + }, + serializers: { + req: (req) => { + const forwardedFor = req.headers?.['x-forwarded-for']; + const ip = + req.headers?.['cf-connecting-ip'] || + (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) || + req.remoteAddress; + + return { + method: req.method, + url: req.url, + ip, + userAgent: req.headers?.['user-agent'], + }; + }, + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + customLogLevel: (_req, res, err) => { + if (res.statusCode >= 500 || err) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + autoLogging: logHttp + ? { + ignore: (req) => + req.url === '/api/health' || req.url === '/api/health/live', + } + : false, + }, + }; +} diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index fdf17523..cc058ac6 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -181,7 +181,9 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.read(attachment.filePath); + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); res.headers({ 'Content-Type': attachment.mimeType, 'Cache-Control': 'private, max-age=3600', @@ -241,7 +243,9 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.read(attachment.filePath); + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); res.headers({ 'Content-Type': attachment.mimeType, 'Cache-Control': 'public, max-age=3600', @@ -367,14 +371,14 @@ export class AttachmentController { const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`; try { - const fileStream = await this.storageService.read(filePath); + const fileStream = await this.storageService.readStream(filePath); res.headers({ 'Content-Type': getMimeType(filePath), 'Cache-Control': 'private, max-age=86400', }); return res.send(fileStream); } catch (err) { - // this.logger.error(err); + // this.logger.error(err); throw new NotFoundException('File not found'); } } diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index ee72dc9f..23512002 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -5,15 +5,17 @@ import { sanitizeFileName } from '../../common/helpers'; import * as sharp from 'sharp'; export interface PreparedFile { - buffer: Buffer; + buffer?: Buffer; fileName: string; fileSize: number; fileExtension: string; mimeType: string; + multiPartFile?: MultipartFile; } export async function prepareFile( filePromise: Promise, + options: { skipBuffer?: boolean } = {}, ): Promise { const file = await filePromise; @@ -22,10 +24,16 @@ export async function prepareFile( } try { - const buffer = await file.toBuffer(); + let buffer: Buffer | undefined; + let fileSize = 0; + + if (!options.skipBuffer) { + buffer = await file.toBuffer(); + fileSize = buffer.length; + } + const sanitizedFilename = sanitizeFileName(file.filename); const fileName = sanitizedFilename.slice(0, 255); - const fileSize = buffer.length; const fileExtension = path.extname(file.filename).toLowerCase(); return { @@ -34,6 +42,7 @@ export async function prepareFile( fileSize, fileExtension, mimeType: file.mimetype, + multiPartFile: file, }; } catch (error) { throw error; diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 77a044a2..ea94b983 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -4,6 +4,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { Readable } from 'stream'; import { StorageService } from '../../../integrations/storage/storage.service'; import { MultipartFile } from '@fastify/multipart'; import { @@ -26,6 +27,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; +import { createByteCountingStream } from '../../../common/helpers/utils'; @Injectable() export class AttachmentService { @@ -49,7 +51,9 @@ export class AttachmentService { attachmentId?: string; }) { const { filePromise, pageId, spaceId, userId, workspaceId } = opts; - const preparedFile: PreparedFile = await prepareFile(filePromise); + const preparedFile: PreparedFile = await prepareFile(filePromise, { + skipBuffer: true, + }); let isUpdate = false; let attachmentId = null; @@ -81,7 +85,14 @@ export class AttachmentService { const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`; - await this.uploadToDrive(filePath, preparedFile.buffer); + const { stream, getBytesRead } = createByteCountingStream( + preparedFile.multiPartFile.file, + ); + + await this.uploadToDrive(filePath, stream); + + // Update fileSize from the consumed stream + preparedFile.fileSize = getBytesRead(); let attachment: Attachment = null; try { @@ -142,7 +153,10 @@ export class AttachmentService { const preparedFile: PreparedFile = await prepareFile(filePromise); validateFileType(preparedFile.fileExtension, validImageExtensions); - const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type); + const processedBuffer = await compressAndResizeIcon( + preparedFile.buffer, + type, + ); preparedFile.buffer = processedBuffer; preparedFile.fileSize = processedBuffer.length; preparedFile.fileName = uuid4() + preparedFile.fileExtension; @@ -232,9 +246,9 @@ export class AttachmentService { } } - async uploadToDrive(filePath: string, fileBuffer: any) { + async uploadToDrive(filePath: string, fileContent: Buffer | Readable) { try { - await this.storageService.upload(filePath, fileBuffer); + await this.storageService.upload(filePath, fileContent); } catch (err) { this.logger.error('Error uploading file to drive:', err); throw new BadRequestException('Error uploading file to drive'); diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 61fb15c9..630605be 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -9,16 +9,14 @@ import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { Comment, Page, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class CommentService { constructor( private commentRepo: CommentRepo, private pageRepo: PageRepo, - private spaceMemberRepo: SpaceMemberRepo, ) {} async findById(commentId: string) { @@ -68,14 +66,14 @@ export class CommentService { async findByPageId( pageId: string, pagination: PaginationOptions, - ): Promise> { + ): Promise> { const page = await this.pageRepo.findById(pageId); if (!page) { throw new BadRequestException('Page not found'); } - return await this.commentRepo.findPageComments(pageId, pagination); + return this.commentRepo.findPageComments(pageId, pagination); } async update( diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 62db153a..fb66eb36 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto'; import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { GroupUserService } from './group-user.service'; @Injectable() @@ -132,12 +132,8 @@ export class GroupService { async getWorkspaceGroups( workspaceId: string, paginationOptions: PaginationOptions, - ): Promise> { - const groups = await this.groupRepo.getGroupsPaginated( - workspaceId, - paginationOptions, - ); - return groups; + ): Promise> { + return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions); } async deleteGroup(groupId: string, workspaceId: string): Promise { diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index 23ab8617..a3e96639 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistory } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class PageHistoryService { @@ -15,12 +15,10 @@ export class PageHistoryService { async findHistoryByPageId( pageId: string, paginationOptions: PaginationOptions, - ): Promise> { - const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId( + ): Promise> { + return this.pageHistoryRepo.findPageHistoryByPageId( pageId, paginationOptions, ); - - return pageHistory; } } diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 9bfb5e1c..3b02e14e 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { - executeWithPagination, - PaginationResult, -} from '@docmost/db/pagination/pagination'; + CursorPaginationResult, + executeWithCursorPagination, +} from '@docmost/db/pagination/cursor-pagination'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; @@ -180,7 +180,7 @@ export class PageService { spaceId: string, pagination: PaginationOptions, pageId?: string, - ): Promise { + ): Promise & { hasChildren: boolean }>> { let query = this.db .selectFrom('pages') .select([ @@ -195,7 +195,6 @@ export class PageService { 'deletedAt', ]) .select((eb) => this.pageRepo.withHasChildren(eb)) - .orderBy('position', (ob) => ob.collate('C').asc()) .where('deletedAt', 'is', null) .where('spaceId', '=', spaceId); @@ -205,12 +204,19 @@ export class PageService { query = query.where('parentPageId', 'is', null); } - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: 250, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() }, + { expression: 'id', direction: 'asc' }, + ], + parseCursor: (cursor) => ({ + position: cursor.position, + id: cursor.id, + }), }); - - return result; } async movePageToSpace(rootPage: Page, spaceId: string) { @@ -259,7 +265,7 @@ export class PageService { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { pageId: pageIds, - workspaceId: rootPage.workspaceId + workspaceId: rootPage.workspaceId, }); } }); @@ -387,9 +393,14 @@ export class PageService { workspaceId: page.workspaceId, creatorId: authUser.id, lastUpdatedById: authUser.id, - parentPageId: page.id === rootPage.id - ? (isDuplicateInSameSpace ? rootPage.parentPageId : null) - : (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null), + parentPageId: + page.id === rootPage.id + ? isDuplicateInSameSpace + ? rootPage.parentPageId + : null + : page.parentPageId + ? pageMap.get(page.parentPageId)?.newPageId + : null, }; }), ); @@ -569,22 +580,22 @@ export class PageService { async getRecentSpacePages( spaceId: string, pagination: PaginationOptions, - ): Promise> { - return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination); + ): Promise> { + return this.pageRepo.getRecentPagesInSpace(spaceId, pagination); } async getRecentPages( userId: string, pagination: PaginationOptions, - ): Promise> { - return await this.pageRepo.getRecentPages(userId, pagination); + ): Promise> { + return this.pageRepo.getRecentPages(userId, pagination); } async getDeletedSpacePages( spaceId: string, pagination: PaginationOptions, - ): Promise> { - return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); + ): Promise> { + return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); } async forceDelete(pageId: string, workspaceId: string): Promise { diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 376f5c9f..53a1f27e 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -26,11 +26,11 @@ export class SearchService { userId?: string; workspaceId: string; }, - ): Promise { + ): Promise<{ items: SearchResponseDto[] }> { const { query } = searchParams; if (query.length < 1) { - return; + return { items: [] }; } const searchQuery = tsquery(query.trim() + '*'); @@ -62,7 +62,7 @@ export class SearchService { ) .where('deletedAt', 'is', null) .orderBy('rank', 'desc') - .limit(searchParams.limit | 25) + .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); if (!searchParams.shareId) { @@ -86,7 +86,7 @@ export class SearchService { const shareId = searchParams.shareId; const share = await this.shareRepo.findById(shareId); if (!share || share.workspaceId !== opts.workspaceId) { - return []; + return { items: [] }; } const pageIdsToSearch = []; @@ -108,10 +108,10 @@ export class SearchService { .where('id', 'in', pageIdsToSearch) .where('workspaceId', '=', opts.workspaceId); } else { - return []; + return { items: [] }; } } else { - return []; + return { items: [] }; } //@ts-ignore @@ -127,7 +127,7 @@ export class SearchService { return result; }); - return searchResults; + return { items: searchResults }; } async searchSuggestions( diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 16ab7c65..93da3e3c 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { SpaceRole } from '../../../common/helpers/types/permission'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class SpaceMemberService { @@ -68,18 +68,16 @@ export class SpaceMemberService { spaceId: string, workspaceId: string, pagination: PaginationOptions, - ) { + ): Promise> { const space = await this.spaceRepo.findById(spaceId, workspaceId); if (!space) { throw new NotFoundException('Space not found'); } - const members = await this.spaceMemberRepo.getSpaceMembersPaginated( + return await this.spaceMemberRepo.getSpaceMembersPaginated( spaceId, pagination, ); - - return members; } async addMembersToSpaceBatch( @@ -276,7 +274,7 @@ export class SpaceMemberService { async getUserSpaces( userId: string, pagination: PaginationOptions, - ): Promise> { - return await this.spaceMemberRepo.getUserSpaces(userId, pagination); + ): Promise> { + return this.spaceMemberRepo.getUserSpaces(userId, pagination); } } diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 0f3c123a..cc811f97 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -8,7 +8,6 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { Space, User } from '@docmost/db/types/entity.types'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; import { UpdateSpaceDto } from '../dto/update-space.dto'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; @@ -17,6 +16,7 @@ import { SpaceRole } from '../../../common/helpers/types/permission'; import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { Queue } from 'bullmq'; import { InjectQueue } from '@nestjs/bullmq'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class SpaceService { @@ -130,13 +130,8 @@ export class SpaceService { async getWorkspaceSpaces( workspaceId: string, pagination: PaginationOptions, - ): Promise> { - const spaces = await this.spaceRepo.getSpacesInWorkspace( - workspaceId, - pagination, - ); - - return spaces; + ): Promise> { + return this.spaceRepo.getSpacesInWorkspace(workspaceId, pagination); } async deleteSpace(spaceId: string, workspaceId: string): Promise { diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 2defcbba..90d5f7b4 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -23,7 +23,7 @@ import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-ac import { TokenService } from '../../auth/services/token.service'; import { nanoIdGen } from '../../../common/helpers'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { DomainService } from 'src/integrations/environment/domain.service'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; @@ -64,12 +64,13 @@ export class WorkspaceInvitationService { ); } - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); - - return result; } async getInvitationById(invitationId: string, workspace: Workspace) { diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 1a5e7f8d..7be5b642 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -19,7 +19,6 @@ import { User } from '@docmost/db/types/entity.types'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { PaginationResult } from '@docmost/db/pagination/pagination'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; @@ -28,12 +27,12 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { addDays } from 'date-fns'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; import { v4 } from 'uuid'; -import { AttachmentType } from 'src/core/attachment/attachment.constants'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; +import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class WorkspaceService { @@ -376,13 +375,8 @@ export class WorkspaceService { async getWorkspaceUsers( workspaceId: string, pagination: PaginationOptions, - ): Promise> { - const users = await this.userRepo.getUsersPaginated( - workspaceId, - pagination, - ); - - return users; + ): Promise> { + return this.userRepo.getUsersPaginated(workspaceId, pagination); } async updateWorkspaceUserRole( diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index bd331ada..e6cb2904 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -7,8 +7,7 @@ import { } from '@nestjs/common'; import { InjectKysely, KyselyModule } from 'nestjs-kysely'; import { EnvironmentService } from '../integrations/environment/environment.service'; -import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely'; -import { Pool, types } from 'pg'; +import { CamelCasePlugin, LogEvent, sql } from 'kysely'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; @@ -26,9 +25,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { PageListener } from '@docmost/db/listeners/page.listener'; - -// https://github.com/brianc/node-postgres/issues/811 -types.setTypeParser(types.builtins.INT8, (val) => Number(val)); +import { PostgresJSDialect } from 'kysely-postgres-js'; +import * as postgres from 'postgres'; +import { normalizePostgresUrl } from '../common/helpers'; @Global() @Module({ @@ -37,26 +36,30 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); imports: [], inject: [EnvironmentService], useFactory: (environmentService: EnvironmentService) => ({ - dialect: new PostgresDialect({ - pool: new Pool({ - connectionString: environmentService.getDatabaseURL(), - max: environmentService.getDatabaseMaxPool(), - }).on('error', (err) => { - console.error('Database error:', err.message); - }), + dialect: new PostgresJSDialect({ + postgres: postgres( + normalizePostgresUrl(environmentService.getDatabaseURL()), + { + max: environmentService.getDatabaseMaxPool(), + onnotice: () => {}, + types: { + bigint: { + to: 20, + from: [20, 1700], + serialize: (value: number) => value.toString(), + parse: (value: string) => Number.parseInt(value), + }, + }, + }, + ), }), plugins: [new CamelCasePlugin()], log: (event: LogEvent) => { if (environmentService.getNodeEnv() !== 'development') return; const logger = new Logger(DatabaseModule.name); - if (event.level) { - if (process.env.DEBUG_DB?.toLowerCase() === 'true') { - logger.debug(event.query.sql); - logger.debug('query time: ' + event.queryDurationMillis + ' ms'); - //if (event.query.parameters.length > 0) { - // logger.debug('parameters: ' + event.query.parameters); - //} - } + if (process.env.DEBUG_DB?.toLowerCase() === 'true') { + logger.debug(event.query.sql); + logger.debug('query time: ' + event.queryDurationMillis + ' ms'); } }, }), diff --git a/apps/server/src/database/migrate.ts b/apps/server/src/database/migrate.ts index 22e62491..a5d58766 100644 --- a/apps/server/src/database/migrate.ts +++ b/apps/server/src/database/migrate.ts @@ -1,25 +1,19 @@ import * as path from 'path'; import { promises as fs } from 'fs'; -import pg from 'pg'; -import { - Kysely, - Migrator, - PostgresDialect, - FileMigrationProvider, -} from 'kysely'; +import { Kysely, Migrator, FileMigrationProvider } from 'kysely'; import { run } from 'kysely-migration-cli'; import * as dotenv from 'dotenv'; -import { envPath } from '../common/helpers/utils'; +import { envPath, normalizePostgresUrl } from '../common/helpers'; +import { PostgresJSDialect } from 'kysely-postgres-js'; +import postgres from 'postgres'; dotenv.config({ path: envPath }); const migrationFolder = path.join(__dirname, './migrations'); const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new pg.Pool({ - connectionString: process.env.DATABASE_URL, - }) as any, + dialect: new PostgresJSDialect({ + postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)), }), }); diff --git a/apps/server/src/database/pagination/cursor-pagination.ts b/apps/server/src/database/pagination/cursor-pagination.ts new file mode 100644 index 00000000..8589a37a --- /dev/null +++ b/apps/server/src/database/pagination/cursor-pagination.ts @@ -0,0 +1,348 @@ +// adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT +import { + OrderByDirection, + OrderByModifiers, + ReferenceExpression, + SelectQueryBuilder, + StringReference, +} from 'kysely'; + +type SortField = + | { + expression: + | (StringReference & keyof O & string) + | (StringReference & `${string}.${keyof O & string}`); + direction: OrderByDirection; + orderModifier?: OrderByModifiers; + key?: keyof O & string; + } + | { + expression: ReferenceExpression; + direction: OrderByDirection; + orderModifier?: OrderByModifiers; + key: keyof O & string; + }; + +type ExtractSortFieldKey< + DB, + TB extends keyof DB, + O, + T extends SortField, +> = T['key'] extends keyof O & string + ? T['key'] + : T['expression'] extends keyof O & string + ? T['expression'] + : T['expression'] extends `${string}.${infer K}` + ? K extends keyof O & string + ? K + : never + : never; + +type Fields = ReadonlyArray< + Readonly> +>; + +type FieldNames> = { + [TIndex in keyof T]: ExtractSortFieldKey; +}; + +type EncodeCursorValues< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = { + [TIndex in keyof T]: [ + ExtractSortFieldKey, + O[ExtractSortFieldKey], + ]; +}; + +export type CursorEncoder< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = (values: EncodeCursorValues) => string; + +type DecodedCursor> = { + [TField in ExtractSortFieldKey]: string; +}; + +export type CursorDecoder< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = ( + cursor: string, + fields: FieldNames, +) => DecodedCursor; + +type ParsedCursorValues< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = { + [TField in ExtractSortFieldKey]: O[TField]; +}; + +export type CursorParser< + DB, + TB extends keyof DB, + O, + T extends Fields, +> = (cursor: DecodedCursor) => ParsedCursorValues; + +type CursorPaginationResultRow< + TRow, + TCursorKey extends string | boolean | undefined, +> = TRow & { + [K in TCursorKey extends undefined + ? never + : TCursorKey extends false + ? never + : TCursorKey extends true + ? '$cursor' + : TCursorKey]: string; +}; + +type CursorPaginationMeta = { + limit: number; + hasNextPage: boolean; + hasPrevPage: boolean; + nextCursor: string | null; + prevCursor: string | null; +}; + +export type CursorPaginationResult< + TRow, + TCursorKey extends string | boolean | undefined = undefined, +> = { + meta: CursorPaginationMeta; + items: CursorPaginationResultRow[]; +}; + +export async function executeWithCursorPagination< + DB, + TB extends keyof DB, + O, + const TFields extends Fields, + TCursorKey extends string | boolean | undefined = undefined, +>( + qb: SelectQueryBuilder, + opts: { + perPage: number; + cursor?: string; + beforeCursor?: string; + cursorPerRow?: TCursorKey; + fields: TFields; + encodeCursor?: CursorEncoder; + decodeCursor?: CursorDecoder; + parseCursor: + | CursorParser + | { parse: CursorParser }; + }, +): Promise> { + const encodeCursor = opts.encodeCursor ?? defaultEncodeCursor; + const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor; + + const parseCursor = + typeof opts.parseCursor === 'function' + ? opts.parseCursor + : opts.parseCursor.parse; + + const fields = opts.fields.map((field) => { + let key = field.key; + + if (!key && typeof field.expression === 'string') { + const expressionParts = field.expression.split('.'); + + key = (expressionParts[1] ?? expressionParts[0]) as + | (keyof O & string) + | undefined; + } + + if (!key) throw new Error('missing key'); + + return { ...field, key }; + }); + + function generateCursor(row: O): string { + const cursorFieldValues = fields.map(({ key }) => [ + key, + row[key], + ]) as EncodeCursorValues; + + return encodeCursor(cursorFieldValues); + } + + const fieldNames = fields.map((field) => field.key) as FieldNames< + DB, + TB, + O, + TFields + >; + + function applyCursor( + qb: SelectQueryBuilder, + encoded: string, + defaultDirection: OrderByDirection, + ) { + const decoded = decodeCursor(encoded, fieldNames); + const cursor = parseCursor(decoded); + + return qb.where(({ and, or, eb }) => { + let expression; + + for (let i = fields.length - 1; i >= 0; --i) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const field = fields[i]!; + + const comparison = field.direction === defaultDirection ? '>' : '<'; + const value = cursor[field.key as keyof typeof cursor]; + + const conditions = [eb(field.expression, comparison, value)]; + + if (expression) { + conditions.push(and([eb(field.expression, '=', value), expression])); + } + + expression = or(conditions); + } + + if (!expression) { + throw new Error('Error building cursor expression'); + } + + return expression; + }); + } + + if (opts.cursor) qb = applyCursor(qb, opts.cursor, 'asc'); + if (opts.beforeCursor) qb = applyCursor(qb, opts.beforeCursor, 'desc'); + + const reversed = !!opts.beforeCursor && !opts.cursor; + + for (const { expression, direction, orderModifier } of fields) { + qb = qb.orderBy( + expression, + orderModifier ?? + (reversed ? (direction === 'asc' ? 'desc' : 'asc') : direction), + ); + } + + const rows = await qb.limit(opts.perPage + 1).execute(); + + const hasNextPage = rows.length > opts.perPage; + + // If we fetched an extra row to determine if we have a next page, that + // shouldn't be in the returned results + if (rows.length > opts.perPage) rows.pop(); + + if (reversed) rows.reverse(); + + const startRow = rows[0]; + const endRow = rows[rows.length - 1]; + + const hasPrevPage = !!opts.cursor; + const prevCursor = hasPrevPage && startRow ? generateCursor(startRow) : null; + const nextCursor = hasNextPage && endRow ? generateCursor(endRow) : null; + + return { + items: rows.map((row) => { + if (opts.cursorPerRow) { + const cursorKey = + typeof opts.cursorPerRow === 'string' ? opts.cursorPerRow : '$cursor'; + + (row as any)[cursorKey] = generateCursor(row); + } + + return row as CursorPaginationResultRow; + }), + meta: { + limit: opts.perPage, + hasNextPage, + hasPrevPage, + nextCursor, + prevCursor, + }, + }; +} + +export function defaultEncodeCursor< + DB, + TB extends keyof DB, + O, + T extends Fields, +>(values: EncodeCursorValues) { + const cursor = new URLSearchParams(); + + for (const [key, value] of values) { + switch (typeof value) { + case 'string': + cursor.set(key, value); + break; + + case 'number': + case 'bigint': + cursor.set(key, value.toString(10)); + break; + + case 'object': { + if (value instanceof Date) { + cursor.set(key, value.toISOString()); + break; + } + } + + // eslint-disable-next-line no-fallthrough + default: + throw new Error(`Unable to encode '${key.toString()}'`); + } + } + + return Buffer.from(cursor.toString(), 'utf8').toString('base64url'); +} + +export function defaultDecodeCursor< + DB, + TB extends keyof DB, + O, + T extends Fields, +>( + cursor: string, + fields: FieldNames, +): DecodedCursor { + let parsed; + + try { + parsed = [ + ...new URLSearchParams( + Buffer.from(cursor, 'base64url').toString('utf8'), + ).entries(), + ]; + } catch { + throw new Error('Unparsable cursor'); + } + + if (parsed.length !== fields.length) { + throw new Error('Unexpected number of fields'); + } + + for (let i = 0; i < fields.length; i++) { + const field = parsed[i]; + const expectedName = fields[i]; + + if (!field) { + throw new Error('Unable to find field'); + } + + if (field[0] !== expectedName) { + throw new Error('Unexpected field name'); + } + } + + return Object.fromEntries(parsed) as DecodedCursor; +} diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts index 63d02f18..49b4d3c0 100644 --- a/apps/server/src/database/pagination/pagination-options.ts +++ b/apps/server/src/database/pagination/pagination-options.ts @@ -9,11 +9,6 @@ import { } from 'class-validator'; export class PaginationOptions { - @IsOptional() - @IsNumber() - @Min(1) - page = 1; - @IsOptional() @IsNumber() @IsPositive() @@ -21,6 +16,14 @@ export class PaginationOptions { @Max(100) limit = 20; + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @IsString() + beforeCursor?: string; + @IsOptional() @IsString() query: string; diff --git a/apps/server/src/database/repos/comment/comment.repo.ts b/apps/server/src/database/repos/comment/comment.repo.ts index 965bd611..9d1a954e 100644 --- a/apps/server/src/database/repos/comment/comment.repo.ts +++ b/apps/server/src/database/repos/comment/comment.repo.ts @@ -8,7 +8,7 @@ import { UpdatableComment, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { ExpressionBuilder } from 'kysely'; import { DB } from '@docmost/db/types/db'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; @@ -37,15 +37,15 @@ export class CommentRepo { .selectAll('comments') .select((eb) => this.withCreator(eb)) .select((eb) => this.withResolvedBy(eb)) - .where('pageId', '=', pageId) - .orderBy('createdAt', 'asc'); + .where('pageId', '=', pageId); - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); - - return result; } async updateComment( diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts index 5c144ec4..7d03957b 100644 --- a/apps/server/src/database/repos/group/group-user.repo.ts +++ b/apps/server/src/database/repos/group/group-user.repo.ts @@ -9,7 +9,7 @@ import { dbOrTx, executeTx } from '@docmost/db/utils'; import { sql } from 'kysely'; import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; @@ -52,8 +52,7 @@ export class GroupUserRepo { .selectFrom('groupUsers') .innerJoin('users', 'users.id', 'groupUsers.userId') .selectAll('users') - .where('groupId', '=', groupId) - .orderBy('createdAt', 'asc'); + .where('groupId', '=', groupId); if (pagination.query) { query = query.where((eb) => @@ -61,9 +60,12 @@ export class GroupUserRepo { ); } - const result = await executeWithPagination(query, { - page: pagination.page, + const result = await executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'users.id', direction: 'asc', key: 'id' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); result.items.map((user) => { diff --git a/apps/server/src/database/repos/group/group.repo.ts b/apps/server/src/database/repos/group/group.repo.ts index 6d0e4257..621cb0c7 100644 --- a/apps/server/src/database/repos/group/group.repo.ts +++ b/apps/server/src/database/repos/group/group.repo.ts @@ -10,8 +10,8 @@ import { import { ExpressionBuilder, sql } from 'kysely'; import { PaginationOptions } from '../../pagination/pagination-options'; import { DB } from '@docmost/db/types/db'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { DefaultGroup } from '../../../core/group/dto/create-group.dto'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; @Injectable() export class GroupRepo { @@ -104,17 +104,19 @@ export class GroupRepo { } async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { - let query = this.db + let baseQuery = this.db .selectFrom('groups') .selectAll('groups') .select((eb) => this.withMemberCount(eb)) - .where('workspaceId', '=', workspaceId) - .orderBy('memberCount', 'desc') - .orderBy('createdAt', 'asc'); + .where('workspaceId', '=', workspaceId); if (pagination.query) { - query = query.where((eb) => - eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( + baseQuery = baseQuery.where((eb) => + eb( + sql`f_unaccent(name)`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, + ).or( sql`f_unaccent(description)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`, @@ -122,12 +124,24 @@ export class GroupRepo { ); } - const result = executeWithPagination(query, { - page: pagination.page, + const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub'); + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { + expression: 'sub.memberCount', + direction: 'desc', + key: 'memberCount', + }, + { expression: 'sub.id', direction: 'asc', key: 'id' }, + ], + parseCursor: (cursor) => ({ + memberCount: parseInt(cursor.memberCount, 10), + id: cursor.id, + }), }); - - return result; } withMemberCount(eb: ExpressionBuilder) { diff --git a/apps/server/src/database/repos/page/page-history.repo.ts b/apps/server/src/database/repos/page/page-history.repo.ts index cfefd485..7152d8e3 100644 --- a/apps/server/src/database/repos/page/page-history.repo.ts +++ b/apps/server/src/database/repos/page/page-history.repo.ts @@ -8,7 +8,7 @@ import { PageHistory, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { ExpressionBuilder } from 'kysely'; import { DB } from '@docmost/db/types/db'; @@ -65,15 +65,15 @@ export class PageHistoryRepo { .selectFrom('pageHistory') .selectAll() .select((eb) => this.withLastUpdatedBy(eb)) - .where('pageId', '=', pageId) - .orderBy('createdAt', 'desc'); + .where('pageId', '=', pageId); - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'desc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); - - return result; } async findPageLastHistory(pageId: string, trx?: KyselyTransaction) { diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index f2b27abb..b9ed90c7 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -8,7 +8,7 @@ import { UpdatablePage, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { validate as isValidUUID } from 'uuid'; import { ExpressionBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; @@ -281,15 +281,21 @@ export class PageRepo { .select(this.baseFields) .select((eb) => this.withSpace(eb)) .where('spaceId', '=', spaceId) - .where('deletedAt', 'is', null) - .orderBy('updatedAt', 'desc'); + .where('deletedAt', 'is', null); - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), }); - - return result; } async getRecentPages(userId: string, pagination: PaginationOptions) { @@ -298,12 +304,20 @@ export class PageRepo { .select(this.baseFields) .select((eb) => this.withSpace(eb)) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)) - .where('deletedAt', 'is', null) - .orderBy('updatedAt', 'desc'); + .where('deletedAt', 'is', null); - return executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), }); } @@ -331,15 +345,21 @@ export class PageRepo { ), ), ]), - ) - .orderBy('deletedAt', 'desc'); + ); - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'deletedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + deletedAt: new Date(cursor.deletedAt), + id: cursor.id, + }), }); - - return result; } withSpace(eb: ExpressionBuilder) { @@ -422,6 +442,8 @@ export class PageRepo { 'parentPageId', 'spaceId', 'workspaceId', + 'createdAt', + 'updatedAt', ]) .$if(opts?.includeContent, (qb) => qb.select('content')) .where('id', '=', parentPageId) @@ -438,6 +460,8 @@ export class PageRepo { 'p.parentPageId', 'p.spaceId', 'p.workspaceId', + 'p.createdAt', + 'p.updatedAt', ]) .$if(opts?.includeContent, (qb) => qb.select('p.content')) .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts index 3cf4ab3b..994f054f 100644 --- a/apps/server/src/database/repos/share/share.repo.ts +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -8,7 +8,7 @@ import { UpdatableShare, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { validate as isValidUUID } from 'uuid'; import { ExpressionBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; @@ -143,12 +143,20 @@ export class ShareRepo { .select((eb) => this.withPage(eb)) .select((eb) => this.withSpace(eb, userId)) .select((eb) => this.withCreator(eb)) - .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)) - .orderBy('updatedAt', 'desc'); + .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)); - return executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), }); } diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts index 64e4ba2c..0f9e78de 100644 --- a/apps/server/src/database/repos/space/space-member.repo.ts +++ b/apps/server/src/database/repos/space/space-member.repo.ts @@ -10,7 +10,7 @@ import { } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; import { MemberInfo, UserSpaceRole } from './types'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; @@ -98,7 +98,7 @@ export class SpaceMemberRepo { spaceId: string, pagination: PaginationOptions, ) { - let query = this.db + let baseQuery = this.db .selectFrom('spaceMembers') .leftJoin('users', 'users.id', 'spaceMembers.userId') .leftJoin('groups', 'groups.id', 'spaceMembers.groupId') @@ -114,12 +114,11 @@ export class SpaceMemberRepo { 'spaceMembers.createdAt', ]) .select((eb) => this.groupRepo.withMemberCount(eb)) - .where('spaceId', '=', spaceId) - .orderBy((eb) => eb('groups.id', 'is not', null), 'desc') - .orderBy('spaceMembers.createdAt', 'asc'); + .select(sql`case when groups.id is not null then 1 else 0 end`.as('isGroup')) + .where('spaceId', '=', spaceId); if (pagination.query) { - query = query.where((eb) => + baseQuery = baseQuery.where((eb) => eb( sql`f_unaccent(users.name)`, 'ilike', @@ -138,9 +137,20 @@ export class SpaceMemberRepo { ); } - const result = await executeWithPagination(query, { - page: pagination.page, + const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub'); + + const result = await executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' }, + { expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' }, + ], + parseCursor: (cursor) => ({ + isGroup: parseInt(cursor.isGroup, 10), + createdAt: new Date(cursor.createdAt), + }), }); let memberInfo: MemberInfo; @@ -235,8 +245,7 @@ export class SpaceMemberRepo { .selectFrom('spaces') .selectAll() .select((eb) => [this.spaceRepo.withMemberCount(eb)]) - .where('id', 'in', this.getUserSpaceIdsQuery(userId)) - .orderBy('createdAt', 'asc'); + .where('id', 'in', this.getUserSpaceIdsQuery(userId)); if (pagination.query) { query = query.where((eb) => @@ -252,9 +261,12 @@ export class SpaceMemberRepo { ); } - return executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); } } diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index ed0d6b1e..e5bb5472 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -9,7 +9,7 @@ import { } from '@docmost/db/types/entity.types'; import { ExpressionBuilder, sql } from 'kysely'; import { PaginationOptions } from '../../pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { DB } from '@docmost/db/types/db'; import { validate as isValidUUID } from 'uuid'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -110,8 +110,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .select((eb) => [this.withMemberCount(eb)]) - .where('workspaceId', '=', workspaceId) - .orderBy('createdAt', 'asc'); + .where('workspaceId', '=', workspaceId); if (pagination.query) { query = query.where((eb) => @@ -127,12 +126,13 @@ export class SpaceRepo { ); } - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); - - return result; } withMemberCount(eb: ExpressionBuilder) { diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index c7c7b2b2..3545e7ec 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -10,7 +10,7 @@ import { User, } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { ExpressionBuilder, sql } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; @@ -145,8 +145,7 @@ export class UserRepo { .selectFrom('users') .select(this.baseFields) .where('workspaceId', '=', workspaceId) - .where('deletedAt', 'is', null) - .orderBy('createdAt', 'asc'); + .where('deletedAt', 'is', null); if (pagination.query) { query = query.where((eb) => @@ -162,12 +161,13 @@ export class UserRepo { ); } - const result = executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'asc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); - - return result; } async updatePreference( diff --git a/apps/server/src/ee b/apps/server/src/ee index fce3e9e9..6d3eb76d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit fce3e9e945da114c4f7cdc4de86a6729b072515e +Subproject commit 6d3eb76d4ef04ad84fb9a5e724de6f94343921cc diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 9d49d108..f5a5c11f 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -55,7 +55,7 @@ export class ExportController { throw new ForbiddenException(); } - const zipFileBuffer = await this.exportService.exportPages( + const zipFileStream = await this.exportService.exportPages( dto.pageId, dto.format, dto.includeAttachments, @@ -70,7 +70,7 @@ export class ExportController { 'attachment; filename="' + encodeURIComponent(fileName) + '"', }); - res.send(zipFileBuffer); + res.send(zipFileStream); } @UseGuards(JwtAuthGuard) @@ -100,6 +100,6 @@ export class ExportController { '"', }); - res.send(exportFile.fileBuffer); + res.send(exportFile.fileStream); } } diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index b8f3a201..655e31d3 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -5,7 +5,6 @@ import { NotFoundException, } from '@nestjs/common'; import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util'; -import { turndown } from './turndown-utils'; import { ExportFormat } from './dto/export-dto'; import { Page } from '@docmost/db/types/entity.types'; import { InjectKysely } from 'nestjs-kysely'; @@ -21,16 +20,23 @@ import { replaceInternalLinks, updateAttachmentUrlsToLocalPaths, } from './utils'; +import { + ExportMetadata, + ExportPageMetadata, +} from '../../common/helpers/types/export-metadata.types'; 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'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const packageJson = require('../../../package.json'); import { EnvironmentService } from '../environment/environment.service'; import { getAttachmentIds, getProsemirrorContent, } from '../../common/helpers/prosemirror/utils'; +import { htmlToMarkdown } from '@docmost/editor-ext'; @Injectable() export class ExportService { @@ -83,7 +89,7 @@ export class ExportService { /]*>[\s\S]*?<\/colgroup>/gim, '', ); - return turndown(newPageHtml); + return htmlToMarkdown(newPageHtml); } return; @@ -155,12 +161,17 @@ export class ExportService { 'pages.id', 'pages.slugId', 'pages.title', + 'pages.icon', + 'pages.position', 'pages.content', 'pages.parentPageId', 'pages.spaceId', 'pages.workspaceId', + 'pages.createdAt', + 'pages.updatedAt', ]) .where('spaceId', '=', spaceId) + .where('deletedAt', 'is', null) .execute(); const tree = buildTree(pages as Page[]); @@ -177,7 +188,7 @@ export class ExportService { const fileName = `${space.name}-space-export.zip`; return { - fileBuffer: zipFile, + fileStream: zipFile, fileName, }; } @@ -189,10 +200,12 @@ export class ExportService { includeAttachments: boolean, ): Promise { const slugIdToPath: Record = {}; + const pageIdToFilePath: Record = {}; + const pagesMetadata: Record = {}; computeLocalPath(tree, format, null, '', slugIdToPath); - const stack: { folder: JSZip; parentPageId: string }[] = [ + const stack: { folder: JSZip; parentPageId: string | null }[] = [ { folder: zip, parentPageId: null }, ]; @@ -232,12 +245,35 @@ export class ExportService { `${pageTitle}${getExportExtension(format)}`, pageExportContent, ); + + pageIdToFilePath[page.id] = currentPagePath; + + const parentPath = parentPageId ? pageIdToFilePath[parentPageId] : null; + pagesMetadata[currentPagePath] = { + pageId: page.id, + slugId: page.slugId, + icon: page.icon ?? null, + position: page.position, + parentPath, + createdAt: page.createdAt?.toISOString() ?? new Date().toISOString(), + updatedAt: page.updatedAt?.toISOString() ?? new Date().toISOString(), + }; + if (childPages.length > 0) { const pageFolder = folder.folder(pageTitle); stack.push({ folder: pageFolder, parentPageId: page.id }); } } } + + const metadata: ExportMetadata = { + exportedAt: new Date().toISOString(), + source: 'docmost', + version: packageJson.version, + pages: pagesMetadata, + }; + + zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2)); } async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) { diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index fe1815b0..266141c2 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -1,4 +1,5 @@ import { jsonToNode } from 'src/collaboration/collaboration.util'; +import { Logger } from '@nestjs/common'; import { ExportFormat } from './dto/export-dto'; import { Node } from '@tiptap/pm/model'; import { validate as isValidUUID } from 'uuid'; @@ -88,7 +89,7 @@ export function replaceInternalLinks( // if link and text are same, use page title if (markLink === node.text) { //@ts-expect-error - node.text = getInternalLinkPageName(relativePath); + node.text = getInternalLinkPageName(relativePath, currentPagePath); } } } @@ -99,10 +100,19 @@ export function replaceInternalLinks( return doc.toJSON(); } -export function getInternalLinkPageName(path: string): string { - return decodeURIComponent( - path?.split('/').pop().split('.').slice(0, -1).join('.'), - ); +export function getInternalLinkPageName(path: string, currentFilePath?: string): string { + const name = path?.split('/').pop().split('.').slice(0, -1).join('.'); + try { + return decodeURIComponent(name); + } catch (err) { + if (currentFilePath) { + Logger.warn( + `URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`, + 'ExportUtils', + ); + } + return name; + } } export function extractPageSlugId(input: string): string { diff --git a/apps/server/src/integrations/import/dto/file-task-dto.ts b/apps/server/src/integrations/import/dto/file-task-dto.ts index 9cdea395..84736813 100644 --- a/apps/server/src/integrations/import/dto/file-task-dto.ts +++ b/apps/server/src/integrations/import/dto/file-task-dto.ts @@ -15,4 +15,5 @@ export type ImportPageNode = { parentPageId: string | null; fileExtension: string; filePath: string; + icon?: string | null; }; \ No newline at end of file diff --git a/apps/server/src/integrations/import/file-task.controller.ts b/apps/server/src/integrations/import/file-task.controller.ts index 096cd5aa..b604d354 100644 --- a/apps/server/src/integrations/import/file-task.controller.ts +++ b/apps/server/src/integrations/import/file-task.controller.ts @@ -27,7 +27,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { FileTaskIdDto } from './dto/file-task-dto'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; -import { executeWithPagination } from '@docmost/db/pagination/pagination'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; @Controller('file-tasks') export class FileTaskController { @@ -56,12 +56,14 @@ export class FileTaskController { const query = this.db .selectFrom('fileTasks') .selectAll() - .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id)) - .orderBy('createdAt', 'desc'); + .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id)); - return executeWithPagination(query, { - page: pagination.page, + return executeWithCursorPagination(query, { perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'id', direction: 'desc' }], + parseCursor: (cursor) => ({ id: cursor.id }), }); } diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 5cf39054..8ae79598 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -24,6 +24,8 @@ import { formatImportHtml } from '../utils/import-formatter'; import { buildAttachmentCandidates, collectMarkdownAndHtmlFiles, + encodeFilePath, + readDocmostMetadata, stripNotionID, } from '../utils/import.utils'; import { executeTx } from '@docmost/db/utils'; @@ -154,6 +156,7 @@ export class FileImportTaskService { const { extractDir, fileTask } = opts; const allFiles = await collectMarkdownAndHtmlFiles(extractDir); const attachmentCandidates = await buildAttachmentCandidates(extractDir); + const docmostMetadata = await readDocmostMetadata(extractDir); const pagesMap = new Map(); @@ -164,6 +167,9 @@ export class FileImportTaskService { .join('/'); // normalize to forward-slashes const ext = path.extname(relPath).toLowerCase(); + const encodedPath = encodeFilePath(relPath); + const pageMetadata = docmostMetadata?.pages[encodedPath]; + pagesMap.set(relPath, { id: v7(), slugId: generateSlugId(), @@ -172,6 +178,7 @@ export class FileImportTaskService { parentPageId: null, fileExtension: ext, filePath: relPath, + icon: pageMetadata?.icon ?? null, }); } @@ -224,6 +231,8 @@ export class FileImportTaskService { if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) { const folderName = path.basename(folderPath); + const encodedMdPath = encodeFilePath(mdPath); + const placeholderMetadata = docmostMetadata?.pages[encodedMdPath]; pagesMap.set(mdPath, { id: v7(), slugId: generateSlugId(), @@ -232,6 +241,7 @@ export class FileImportTaskService { parentPageId: null, fileExtension: '.md', filePath: mdPath, + icon: placeholderMetadata?.icon ?? null, }); } }); @@ -266,11 +276,39 @@ export class FileImportTaskService { siblingsMap.set(page.parentPageId, group); }); + const encodedPathsMap = new Map(); + if (docmostMetadata) { + pagesMap.forEach((_, filePath) => { + encodedPathsMap.set(filePath, encodeFilePath(filePath)); + }); + } + + // Sort siblings by metadata position if available, otherwise alphabetically + const sortSiblings = (siblings: ImportPageNode[]) => { + if (docmostMetadata) { + siblings.sort((a, b) => { + const posA = + docmostMetadata.pages[encodedPathsMap.get(a.filePath)]?.position; + const posB = + docmostMetadata.pages[encodedPathsMap.get(b.filePath)]?.position; + if (posA && posB) { + // Use direct comparison to match PostgreSQL collation 'C' (byte order) + if (posA < posB) return -1; + if (posA > posB) return 1; + return 0; + } + return a.name.localeCompare(b.name); + }); + } else { + siblings.sort((a, b) => a.name.localeCompare(b.name)); + } + }; + // get root pages const rootSibs = siblingsMap.get(null); if (rootSibs?.length) { - rootSibs.sort((a, b) => a.name.localeCompare(b.name)); + sortSiblings(rootSibs); // get first position key from the server const nextPosition = await this.pageService.nextPagePosition( @@ -292,7 +330,7 @@ export class FileImportTaskService { siblingsMap.forEach((sibs, parentId) => { if (parentId === null) return; // root already done - sibs.sort((a, b) => a.name.localeCompare(b.name)); + sortSiblings(sibs); let prevPos: string | null = null; for (const page of sibs) { @@ -426,7 +464,7 @@ export class FileImportTaskService { id: page.id, slugId: page.slugId, title: title || page.name, - icon: pageIcon || null, + icon: page.icon || pageIcon || null, content: prosemirrorJson, textContent: jsonToText(prosemirrorJson), ydoc: await this.importService.createYdoc(prosemirrorJson), diff --git a/apps/server/src/integrations/import/services/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts index 7901122a..aeeebcee 100644 --- a/apps/server/src/integrations/import/services/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -10,7 +10,11 @@ import { } from '../../../collaboration/collaboration.util'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; -import { generateSlugId, sanitizeFileName } from '../../../common/helpers'; +import { + generateSlugId, + sanitizeFileName, + createByteCountingStream, +} from '../../../common/helpers'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { TiptapTransformer } from '@hocuspocus/transformer'; import * as Y from 'yjs'; @@ -173,15 +177,24 @@ export class ImportService { }; } - async getNewPagePosition(spaceId: string): Promise { - const lastPage = await this.db + async getNewPagePosition( + spaceId: string, + parentPageId?: string, + ): Promise { + let query = this.db .selectFrom('pages') .select(['id', 'position']) .where('spaceId', '=', spaceId) .orderBy('position', (ob) => ob.collate('C').desc()) - .limit(1) - .where('parentPageId', 'is', null) - .executeTakeFirst(); + .limit(1); + + if (parentPageId) { + query = query.where('parentPageId', '=', parentPageId); + } else { + query = query.where('parentPageId', 'is', null); + } + + const lastPage = await query.executeTakeFirst(); if (lastPage) { return generateJitteredKeyBetween(lastPage.position, null); @@ -198,20 +211,21 @@ export class ImportService { workspaceId: string, ) { const file = await filePromise; - const fileBuffer = await file.toBuffer(); const fileExtension = path.extname(file.filename).toLowerCase(); const fileName = sanitizeFileName( path.basename(file.filename, fileExtension), ); - const fileSize = fileBuffer.length; - const fileNameWithExt = fileName + fileExtension; const fileTaskId = uuid7(); const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`; // upload file - await this.storageService.upload(filePath, fileBuffer); + const { stream, getBytesRead } = createByteCountingStream(file.file); + + await this.storageService.upload(filePath, stream); + + const fileSize = getBytesRead(); const fileTask = await this.db .insertInto('fileTasks') diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 14a2530c..59f5eeec 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -1,4 +1,5 @@ import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; +import { Logger } from '@nestjs/common'; import * as path from 'path'; import { v7 } from 'uuid'; import { InsertableBacklink } from '@docmost/db/types/entity.types'; @@ -280,8 +281,18 @@ export async function rewriteInternalLinksToMentionHtml( const $a = $(el); const raw = $a.attr('href')!; if (raw.startsWith('http') || raw.startsWith('/api/')) return; + let decodedRaw = raw; + try { + decodedRaw = decodeURIComponent(raw); + } catch (err) { + Logger.warn( + `URI malformed in page ${currentFilePath}: ${raw}. Falling back to raw path.`, + 'ImportFormatter', + ); + } + const resolved = normalize( - path.join(path.dirname(currentFilePath), decodeURIComponent(raw)), + path.join(path.dirname(currentFilePath), decodedRaw), ); const meta = filePathToPageMetaMap.get(resolved); if (!meta) return; diff --git a/apps/server/src/integrations/import/utils/import.utils.ts b/apps/server/src/integrations/import/utils/import.utils.ts index 1fa10d7a..cd348652 100644 --- a/apps/server/src/integrations/import/utils/import.utils.ts +++ b/apps/server/src/integrations/import/utils/import.utils.ts @@ -1,5 +1,7 @@ +import { Logger } from '@nestjs/common'; import { promises as fs } from 'fs'; import * as path from 'path'; +import { ExportMetadata } from '../../../common/helpers/types/export-metadata.types'; export async function buildAttachmentCandidates( extractDir: string, @@ -30,8 +32,19 @@ export function resolveRelativeAttachmentPath( pageDir: string, attachmentCandidates: Map, ): string | null { - const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, '')); - const fallback = path.normalize(path.join(pageDir, mainRel)); + let mainRel = raw.replace(/^\.?\/+/, ''); + try { + mainRel = decodeURIComponent(mainRel); + } catch (err) { + Logger.warn( + `URI malformed for attachment path: ${mainRel}. Falling back to raw path.`, + 'ImportUtils', + ); + } + const fallback = path + .normalize(path.join(pageDir, mainRel)) + .split(path.sep) + .join('/'); if (attachmentCandidates.has(mainRel)) { return mainRel; @@ -70,3 +83,26 @@ export function stripNotionID(fileName: string): string { const notionIdPattern = /[ -]?[a-z0-9]{32}$/i; return fileName.replace(notionIdPattern, '').trim(); } + +export function encodeFilePath(filePath: string): string { + return filePath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +export async function readDocmostMetadata( + extractDir: string, +): Promise { + const metadataPath = path.join(extractDir, 'docmost-metadata.json'); + try { + const content = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(content) as ExportMetadata; + if (metadata.source === 'docmost' && metadata.pages) { + return metadata; + } + return null; + } catch { + return null; + } +} diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index 5171066c..aada2c05 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -20,9 +20,15 @@ export class LocalDriver implements StorageDriver { return join(this.config.storagePath, filePath); } - async upload(filePath: string, file: Buffer): Promise { + async upload(filePath: string, file: Buffer | Readable): Promise { try { - await fs.outputFile(this._fullPath(filePath), file); + const fullPath = this._fullPath(filePath); + if (file instanceof Buffer) { + await fs.outputFile(fullPath, file); + } else { + await fs.mkdir(dirname(fullPath), { recursive: true }); + await pipeline(file, createWriteStream(fullPath)); + } } catch (err) { throw new Error(`Failed to upload file: ${(err as Error).message}`); } @@ -42,7 +48,7 @@ export class LocalDriver implements StorageDriver { try { const fromFullPath = this._fullPath(fromFilePath); const toFullPath = this._fullPath(toFilePath); - + if (await this.exists(fromFilePath)) { await fs.copy(fromFullPath, toFullPath); } diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index f6d48677..ed44fded 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -23,19 +23,21 @@ export class S3Driver implements StorageDriver { this.s3Client = new S3Client(config as any); } - async upload(filePath: string, file: Buffer): Promise { + async upload(filePath: string, file: Buffer | Readable): Promise { try { const contentType = getMimeType(filePath); - const command = new PutObjectCommand({ - Bucket: this.config.bucket, - Key: filePath, - Body: file, - ContentType: contentType, - // ACL: "public-read", + const upload = new Upload({ + client: this.s3Client, + params: { + Bucket: this.config.bucket, + Key: filePath, + Body: file, + ContentType: contentType, + }, }); - await this.s3Client.send(command); + await upload.done(); } catch (err) { throw new Error(`Failed to upload file: ${(err as Error).message}`); } diff --git a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts index 22a86d2b..f376c56f 100644 --- a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts +++ b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts @@ -1,7 +1,7 @@ import { Readable } from 'stream'; export interface StorageDriver { - upload(filePath: string, file: Buffer): Promise; + upload(filePath: string, file: Buffer | Readable): Promise; uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise; diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index d796351b..3ed887af 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -8,9 +8,9 @@ export class StorageService { private readonly logger = new Logger(StorageService.name); constructor( @Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver, - ) {} + ) { } - async upload(filePath: string, fileContent: Buffer | any) { + async upload(filePath: string, fileContent: Buffer | Readable) { await this.storageDriver.upload(filePath, fileContent); this.logger.debug(`File uploaded successfully. Path: ${filePath}`); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 79340d6e..8b18a011 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -5,9 +5,9 @@ import { NestFastifyApplication, } from '@nestjs/platform-fastify'; import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common'; +import { Logger as PinoLogger } from 'nestjs-pino'; import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor'; import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter'; -import { InternalLogFilter } from './common/logger/internal-log-filter'; import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; @@ -24,10 +24,16 @@ async function bootstrap() { }), { rawBody: true, - logger: new InternalLogFilter(), + // disable Nest logger so pino handles all logs + // bufferLogs must be false else pino will fail + // to log OnApplicationBootstrap logs + logger: false, + bufferLogs: false, }, ); + app.useLogger(app.get(PinoLogger)); + app.setGlobalPrefix('api', { exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'], }); @@ -98,7 +104,8 @@ async function bootstrap() { }); const port = process.env.PORT || 3000; - await app.listen(port, '0.0.0.0', () => { + const host = process.env.HOST || '0.0.0.0'; + await app.listen(port, host, () => { logger.log( `Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`, ); diff --git a/apps/server/src/ws/adapter/ws-redis.adapter.ts b/apps/server/src/ws/adapter/ws-redis.adapter.ts index 5aae1c7c..a221c84a 100644 --- a/apps/server/src/ws/adapter/ws-redis.adapter.ts +++ b/apps/server/src/ws/adapter/ws-redis.adapter.ts @@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter { const pubClient = new Redis(process.env.REDIS_URL, options); const subClient = new Redis(process.env.REDIS_URL, options); + pubClient.on('error', (err) => () => {}); + subClient.on('error', (err) => () => {}); + this.adapterConstructor = createAdapter(pubClient, subClient); } diff --git a/package.json b/package.json index e7550c6a..771f0ae7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "docmost", "homepage": "https://docmost.com", - "version": "0.24.1", + "version": "0.25.0-beta.1", "private": true, "scripts": { "build": "nx run-many -t build", @@ -20,56 +20,50 @@ }, "dependencies": { "@braintree/sanitize-url": "^7.1.0", - "@casl/ability": "^6.7.5", + "@casl/ability": "6.8.0", "@docmost/editor-ext": "workspace:*", "@floating-ui/dom": "^1.7.3", - "@hocuspocus/extension-redis": "^2.15.3", - "@hocuspocus/provider": "^2.15.3", - "@hocuspocus/server": "^2.15.3", - "@hocuspocus/transformer": "^2.15.3", + "@hocuspocus/provider": "3.4.3", + "@hocuspocus/server": "3.4.3", + "@hocuspocus/transformer": "3.4.3", "@joplin/turndown": "^4.0.74", "@joplin/turndown-plugin-gfm": "^1.0.56", "@sindresorhus/slugify": "1.1.0", - "@tiptap/core": "2.27.1", - "@tiptap/extension-code-block": "2.27.1", - "@tiptap/extension-code-block-lowlight": "2.27.1", - "@tiptap/extension-collaboration": "2.27.1", - "@tiptap/extension-collaboration-cursor": "2.27.1", - "@tiptap/extension-color": "2.27.1", - "@tiptap/extension-document": "2.27.1", - "@tiptap/extension-heading": "2.27.1", - "@tiptap/extension-highlight": "2.27.1", - "@tiptap/extension-history": "2.27.1", - "@tiptap/extension-image": "2.27.1", - "@tiptap/extension-link": "2.27.1", - "@tiptap/extension-list-item": "2.27.1", - "@tiptap/extension-list-keymap": "2.27.1", - "@tiptap/extension-placeholder": "2.27.1", - "@tiptap/extension-subscript": "2.27.1", - "@tiptap/extension-superscript": "2.27.1", - "@tiptap/extension-table": "2.27.1", - "@tiptap/extension-table-cell": "2.27.1", - "@tiptap/extension-table-header": "2.27.1", - "@tiptap/extension-table-row": "2.27.1", - "@tiptap/extension-task-item": "2.27.1", - "@tiptap/extension-task-list": "2.27.1", - "@tiptap/extension-text": "2.27.1", - "@tiptap/extension-text-align": "2.27.1", - "@tiptap/extension-text-style": "2.27.1", - "@tiptap/extension-typography": "2.27.1", - "@tiptap/extension-underline": "2.27.1", - "@tiptap/extension-youtube": "2.27.1", - "@tiptap/html": "2.27.1", - "@tiptap/pm": "2.27.1", - "@tiptap/react": "2.27.1", - "@tiptap/starter-kit": "2.27.1", - "@tiptap/suggestion": "2.27.1", + "@tiptap/core": "3.17.1", + "@tiptap/extension-code-block": "3.17.1", + "@tiptap/extension-collaboration": "3.17.1", + "@tiptap/extension-collaboration-caret": "3.17.1", + "@tiptap/extension-color": "3.17.1", + "@tiptap/extension-document": "3.17.1", + "@tiptap/extension-heading": "3.17.1", + "@tiptap/extension-highlight": "3.17.1", + "@tiptap/extension-history": "3.17.1", + "@tiptap/extension-image": "3.17.1", + "@tiptap/extension-link": "3.17.1", + "@tiptap/extension-list": "3.17.1", + "@tiptap/extension-placeholder": "3.17.1", + "@tiptap/extension-subscript": "3.17.1", + "@tiptap/extension-superscript": "3.17.1", + "@tiptap/extension-table": "3.17.1", + "@tiptap/extension-text": "3.17.1", + "@tiptap/extension-text-align": "3.17.1", + "@tiptap/extension-text-style": "3.17.1", + "@tiptap/extension-typography": "3.17.1", + "@tiptap/extension-unique-id": "^3.17.1", + "@tiptap/extension-youtube": "3.17.1", + "@tiptap/html": "3.17.1", + "@tiptap/pm": "3.17.1", + "@tiptap/react": "3.17.1", + "@tiptap/starter-kit": "3.17.1", + "@tiptap/suggestion": "3.17.1", "@types/qrcode": "^1.5.5", "bytes": "^3.1.2", "cross-env": "^7.0.3", "date-fns": "^4.1.0", "dompurify": "^3.2.6", "fractional-indexing-jittered": "^1.0.0", + "highlight.js": "^11.11.1", + "image-dimensions": "^2.5.0", "ioredis": "^5.4.1", "jszip": "^3.10.1", "linkifyjs": "^4.3.2", @@ -79,11 +73,12 @@ "uuid": "^11.1.0", "y-indexeddb": "^9.0.12", "y-prosemirror": "1.3.7", - "yjs": "^13.6.27" + "yjs": "^13.6.29" }, "devDependencies": { "@nx/js": "20.4.5", "@types/bytes": "^3.1.5", + "@types/turndown": "^5.0.6", "@types/uuid": "^10.0.0", "concurrently": "^9.1.2", "nx": "20.4.5", diff --git a/packages/editor-ext/.prettierrc b/packages/editor-ext/.prettierrc new file mode 100644 index 00000000..dcb72794 --- /dev/null +++ b/packages/editor-ext/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 59cd3b88..102cc4b1 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -23,4 +23,5 @@ export * from "./lib/subpages"; export * from "./lib/highlight"; export * from "./lib/heading/heading"; export * from "./lib/unique-id"; +export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts index 0d2ac6c7..a3446db9 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -1,126 +1,125 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { Node } from "@tiptap/pm/model"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("attachment-upload"); +const findAttachmentNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const AttachmentUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, fileName } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", placeholderClass); - - const uploadingText = document.createElement("span"); - uploadingText.setAttribute("class", "uploading-text"); - uploadingText.textContent = `Uploading ${fileName}`; - - placeholder.appendChild(uploadingText); - - const realPos = pos + 1; - const deco = Decoration.widget(realPos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "attachment" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleAttachmentUpload = + return result; +}; +const handleAttachmentUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId, allowMedia) => { + async (file, editor, pos, pageId, allowMedia) => { const validated = validateFn?.(file, allowMedia); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + const placeholderId = generateNodeId(); - tr.setMeta(uploadKey, { - add: { - id, - pos, - fileName: file.name, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.attachment?.create({ + placeholder: { + id: placeholderId, + }, + name: file.name, + size: file.size, + }); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + if (!initialPlaceholderNode) return false; - const pos = findPlaceholder(view.state, id); + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; - if (pos == null) return; + if (isEmptyTextBlock) { + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } - if (!attachment) return; + return true; + }; + }; + const replacePlaceholderWithAttachment = ( + attachment: IAttachment, + ): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const node = schema.nodes.attachment?.create({ + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return false; + + // Update the placeholder node with the actual attachment data + tr.setNodeMarkup(currentPos, undefined, { url: `/api/files/${attachment.id}/${attachment.fileName}`, name: attachment.fileName, mime: attachment.mimeType, size: attachment.fileSize, attachmentId: attachment.id, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithAttachment(attachment)); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithAttachment(attachment)) + .run(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + } }; + +export { handleAttachmentUpload }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index 5231c897..a1e851a4 100644 --- a/packages/editor-ext/src/lib/attachment/attachment.ts +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -1,6 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { AttachmentUploadPlugin } from "./attachment-upload"; export interface AttachmentOptions { HTMLAttributes: Record; @@ -13,6 +12,7 @@ export interface AttachmentAttributes { mime?: string; // e.g. application/zip size?: number; attachmentId?: string; + placeholder?: string; } declare module "@tiptap/core" { @@ -75,6 +75,10 @@ export const Attachment = Node.create({ "data-attachment-id": attributes.attachmentId, }), }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -92,7 +96,7 @@ export const Attachment = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), [ "a", @@ -120,14 +124,9 @@ export const Attachment = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - AttachmentUploadPlugin({ - placeholderClass: "attachment-placeholder", - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/callout/callout.ts b/packages/editor-ext/src/lib/callout/callout.ts index 97c5dfcc..1dc4d800 100644 --- a/packages/editor-ext/src/lib/callout/callout.ts +++ b/packages/editor-ext/src/lib/callout/callout.ts @@ -87,7 +87,7 @@ export const Callout = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), 0, ]; @@ -130,6 +130,9 @@ export const Callout = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, @@ -193,7 +196,7 @@ export const Callout = Node.create({ tr.delete(pos, pos + nodeSize); tr.setSelection( - TextSelection.near(tr.doc.resolve(previousPosition - 1)), + TextSelection.near(tr.doc.resolve(previousPosition - 1)) ); tr.insert(previousPosition - 1, content); diff --git a/packages/editor-ext/src/lib/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block.ts deleted file mode 100644 index 702e98a9..00000000 --- a/packages/editor-ext/src/lib/custom-code-block.ts +++ /dev/null @@ -1,81 +0,0 @@ -import CodeBlockLowlight, { - CodeBlockLowlightOptions, -} from "@tiptap/extension-code-block-lowlight"; -import { ReactNodeViewRenderer } from "@tiptap/react"; - -export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions { - view: any; -} - -const TAB_CHAR = "\u00A0\u00A0"; - -export const CustomCodeBlock = CodeBlockLowlight.extend( - { - selectable: true, - - addOptions() { - return { - ...this.parent?.(), - view: null, - }; - }, - - addKeyboardShortcuts() { - return { - ...this.parent?.(), - Tab: () => { - if (this.editor.isActive("codeBlock")) { - this.editor - .chain() - .command(({ tr }) => { - tr.insertText(TAB_CHAR); - return true; - }) - .run(); - return true; - } - }, - "Mod-a": () => { - if (this.editor.isActive("codeBlock")) { - const { state } = this.editor; - const { $from } = state.selection; - - let codeBlockNode = null; - let codeBlockPos = null; - let depth = 0; - - for (depth = $from.depth; depth > 0; depth--) { - const node = $from.node(depth); - if (node.type.name === "codeBlock") { - codeBlockNode = node; - codeBlockPos = $from.start(depth) - 1; - break; - } - } - - if (codeBlockNode && codeBlockPos !== null) { - const codeBlockStart = codeBlockPos; - const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize; - - const contentStart = codeBlockStart + 1; - const contentEnd = codeBlockEnd - 1; - - this.editor.commands.setTextSelection({ - from: contentStart, - to: contentEnd, - }); - - return true; - } - } - - return false; - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(this.options.view); - }, - } -); diff --git a/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts new file mode 100644 index 00000000..ba9fe9c1 --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/custom-code-block.ts @@ -0,0 +1,108 @@ +import type { CodeBlockOptions } from "@tiptap/extension-code-block"; +import CodeBlock from "@tiptap/extension-code-block"; + +import { LowlightPlugin } from "./lowlight-plugin.js"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface CodeBlockLowlightOptions extends CodeBlockOptions { + /** + * The lowlight instance. + */ + lowlight: any; + view: any; +} + +const TAB_CHAR = "\u00A0\u00A0"; + +/** + * This extension allows you to highlight code blocks with lowlight. + * @see https://tiptap.dev/api/nodes/code-block-lowlight + */ +export const CustomCodeBlock = CodeBlock.extend({ + selectable: true, + + addOptions() { + return { + ...this.parent?.(), + lowlight: {}, + languageClassPrefix: "language-", + exitOnTripleEnter: true, + exitOnArrowDown: true, + defaultLanguage: null, + HTMLAttributes: {}, + view: null, + }; + }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Tab: () => { + if (this.editor.isActive("codeBlock")) { + this.editor + .chain() + .command(({ tr }) => { + tr.insertText(TAB_CHAR); + return true; + }) + .run(); + return true; + } + }, + "Mod-a": () => { + if (this.editor.isActive("codeBlock")) { + const { state } = this.editor; + const { $from } = state.selection; + + let codeBlockNode = null; + let codeBlockPos = null; + let depth = 0; + + for (depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "codeBlock") { + codeBlockNode = node; + codeBlockPos = $from.start(depth) - 1; + break; + } + } + + if (codeBlockNode && codeBlockPos !== null) { + const codeBlockStart = codeBlockPos; + const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize; + + const contentStart = codeBlockStart + 1; + const contentEnd = codeBlockEnd - 1; + + this.editor.commands.setTextSelection({ + from: contentStart, + to: contentEnd, + }); + + return true; + } + } + + return false; + }, + }; + }, + + addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + + return ReactNodeViewRenderer(this.options.view); + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + LowlightPlugin({ + name: this.name, + lowlight: this.options.lowlight, + defaultLanguage: this.options.defaultLanguage, + }), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/custom-code-block/index.ts b/packages/editor-ext/src/lib/custom-code-block/index.ts new file mode 100644 index 00000000..f6e3470f --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/index.ts @@ -0,0 +1 @@ +export { CustomCodeBlock } from "./custom-code-block"; diff --git a/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts b/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts new file mode 100644 index 00000000..505b8f20 --- /dev/null +++ b/packages/editor-ext/src/lib/custom-code-block/lowlight-plugin.ts @@ -0,0 +1,159 @@ +import { findChildren } from '@tiptap/core' +import type { Node as ProsemirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +// @ts-ignore +import highlight from 'highlight.js/lib/core' + +function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] { + return nodes + .map(node => { + const classes = [...className, ...(node.properties ? node.properties.className : [])] + + if (node.children) { + return parseNodes(node.children, classes) + } + + return { + text: node.value, + classes, + } + }) + .flat() +} + +function getHighlightNodes(result: any) { + // `.value` for lowlight v1, `.children` for lowlight v2 + return result.value || result.children || [] +} + +function registered(aliasOrLanguage: string) { + return Boolean(highlight.getLanguage(aliasOrLanguage)) +} + +function getDecorations({ + doc, + name, + lowlight, + defaultLanguage, +}: { + doc: ProsemirrorNode + name: string + lowlight: any + defaultLanguage: string | null | undefined +}) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === name).forEach(block => { + let from = block.pos + 1 + const language = block.node.attrs.language || defaultLanguage + const languages = lowlight.listLanguages() + + const nodes = + language && (languages.includes(language) || registered(language) || lowlight.registered?.(language)) + ? getHighlightNodes(lowlight.highlight(language, block.node.textContent)) + : getHighlightNodes(lowlight.highlightAuto(block.node.textContent)) + + parseNodes(nodes).forEach(node => { + const to = from + node.text.length + + if (node.classes.length) { + const decoration = Decoration.inline(from, to, { + class: node.classes.join(' '), + }) + + decorations.push(decoration) + } + + from = to + }) + }) + + return DecorationSet.create(doc, decorations) +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +function isFunction(param: any): param is Function { + return typeof param === 'function' +} + +export function LowlightPlugin({ + name, + lowlight, + defaultLanguage, +}: { + name: string + lowlight: any + defaultLanguage: string | null | undefined +}) { + if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) { + throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension') + } + + const lowlightPlugin: Plugin = new Plugin({ + key: new PluginKey('lowlight'), + + state: { + init: (_, { doc }) => + getDecorations({ + doc, + name, + lowlight, + defaultLanguage, + }), + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name + const newNodeName = newState.selection.$head.parent.type.name + const oldNodes = findChildren(oldState.doc, node => node.type.name === name) + const newNodes = findChildren(newState.doc, node => node.type.name === name) + + if ( + transaction.docChanged && + // Apply decorations if: + // selection includes named node, + ([oldNodeName, newNodeName].includes(name) || + // OR transaction adds/removes named node, + newNodes.length !== oldNodes.length || + // OR transaction has changes that completely encapsulte a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + transaction.steps.some(step => { + // @ts-ignore + return ( + // @ts-ignore + step.from !== undefined && + // @ts-ignore + step.to !== undefined && + oldNodes.some(node => { + // @ts-ignore + return ( + // @ts-ignore + node.pos >= step.from && + // @ts-ignore + node.pos + node.node.nodeSize <= step.to + ) + }) + ) + })) + ) { + return getDecorations({ + doc: transaction.doc, + name, + lowlight, + defaultLanguage, + }) + } + + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return lowlightPlugin.getState(state) + }, + }, + }) + + return lowlightPlugin +} \ No newline at end of file diff --git a/packages/editor-ext/src/lib/details/details.ts b/packages/editor-ext/src/lib/details/details.ts index b28c4de7..41c66dca 100644 --- a/packages/editor-ext/src/lib/details/details.ts +++ b/packages/editor-ext/src/lib/details/details.ts @@ -27,6 +27,7 @@ export const Details = Node.create({ content: "detailsSummary detailsContent", defining: true, isolating: true, + // @ts-ignore allowGapCursor: false, addOptions() { return { diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index 319853b2..3cc041a2 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -41,45 +41,45 @@ export const Drawio = Node.create({ addAttributes() { return { src: { - default: '', - parseHTML: (element) => element.getAttribute('data-src'), + default: "", + parseHTML: (element) => element.getAttribute("data-src"), renderHTML: (attributes) => ({ - 'data-src': attributes.src, + "data-src": attributes.src, }), }, title: { default: undefined, - parseHTML: (element) => element.getAttribute('data-title'), + parseHTML: (element) => element.getAttribute("data-title"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-title': attributes.title, + "data-title": attributes.title, }), }, width: { - default: '100%', - parseHTML: (element) => element.getAttribute('data-width'), + default: "100%", + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, size: { default: null, - parseHTML: (element) => element.getAttribute('data-size'), + parseHTML: (element) => element.getAttribute("data-size"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-size': attributes.size, + "data-size": attributes.size, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, attachmentId: { default: undefined, - parseHTML: (element) => element.getAttribute('data-attachment-id'), + parseHTML: (element) => element.getAttribute("data-attachment-id"), renderHTML: (attributes: DrawioAttributes) => ({ - 'data-attachment-id': attributes.attachmentId, + "data-attachment-id": attributes.attachmentId, }), }, }; @@ -95,13 +95,20 @@ export const Drawio = Node.create({ renderHTML({ HTMLAttributes }) { return [ - 'div', + "div", mergeAttributes( - { 'data-type': this.name }, + { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ), - ['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], + [ + "img", + { + src: HTMLAttributes["data-src"], + alt: HTMLAttributes["data-title"], + width: HTMLAttributes["data-width"], + }, + ], ]; }, @@ -119,6 +126,9 @@ export const Drawio = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/embed-provider.ts b/packages/editor-ext/src/lib/embed-provider.ts index 7a91ae9f..4c286a8b 100644 --- a/packages/editor-ext/src/lib/embed-provider.ts +++ b/packages/editor-ext/src/lib/embed-provider.ts @@ -99,7 +99,7 @@ export const embedProviders: IEmbedProvider[] = [ id: "gsheets", name: "Google Sheets", regex: - /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/, + /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/, getEmbedUrl: (match, url: string) => { return url; }, diff --git a/packages/editor-ext/src/lib/embed.ts b/packages/editor-ext/src/lib/embed.ts index 47fc251e..a93648b1 100644 --- a/packages/editor-ext/src/lib/embed.ts +++ b/packages/editor-ext/src/lib/embed.ts @@ -1,6 +1,6 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; -import { sanitizeUrl } from './utils'; +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "./utils"; export interface EmbedOptions { HTMLAttributes: Record; @@ -14,7 +14,7 @@ export interface EmbedAttributes { height?: number; } -declare module '@tiptap/core' { +declare module "@tiptap/core" { interface Commands { embeds: { setEmbed: (attributes?: EmbedAttributes) => ReturnType; @@ -23,9 +23,9 @@ declare module '@tiptap/core' { } export const Embed = Node.create({ - name: 'embed', + name: "embed", inline: false, - group: 'block', + group: "block", isolating: true, atom: true, defining: true, @@ -40,41 +40,41 @@ export const Embed = Node.create({ addAttributes() { return { src: { - default: '', + default: "", parseHTML: (element) => { - const src = element.getAttribute('data-src'); + const src = element.getAttribute("data-src"); return sanitizeUrl(src); }, renderHTML: (attributes: EmbedAttributes) => ({ - 'data-src': sanitizeUrl(attributes.src), + "data-src": sanitizeUrl(attributes.src), }), }, provider: { - default: '', - parseHTML: (element) => element.getAttribute('data-provider'), + default: "", + parseHTML: (element) => element.getAttribute("data-provider"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-provider': attributes.provider, + "data-provider": attributes.provider, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, width: { default: 640, - parseHTML: (element) => element.getAttribute('data-width'), + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, height: { default: 480, - parseHTML: (element) => element.getAttribute('data-height'), + parseHTML: (element) => element.getAttribute("data-height"), renderHTML: (attributes: EmbedAttributes) => ({ - 'data-height': attributes.height, + "data-height": attributes.height, }), }, }; @@ -91,13 +91,13 @@ export const Embed = Node.create({ renderHTML({ HTMLAttributes }) { const src = HTMLAttributes["data-src"]; const safeHref = sanitizeUrl(src); - + return [ "div", mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), [ "a", @@ -120,9 +120,9 @@ export const Embed = Node.create({ ...attrs, src: sanitizeUrl(attrs.src), }; - + return commands.insertContent({ - type: 'embed', + type: "embed", attrs: validatedAttrs, }); }, @@ -130,6 +130,9 @@ export const Embed = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index a7e3a468..28b064e4 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -1,5 +1,5 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; export interface ExcalidrawOptions { HTMLAttributes: Record; @@ -14,7 +14,7 @@ export interface ExcalidrawAttributes { attachmentId?: string; } -declare module '@tiptap/core' { +declare module "@tiptap/core" { interface Commands { excalidraw: { setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; @@ -23,9 +23,9 @@ declare module '@tiptap/core' { } export const Excalidraw = Node.create({ - name: 'excalidraw', + name: "excalidraw", inline: false, - group: 'block', + group: "block", isolating: true, atom: true, defining: true, @@ -40,45 +40,45 @@ export const Excalidraw = Node.create({ addAttributes() { return { src: { - default: '', - parseHTML: (element) => element.getAttribute('data-src'), + default: "", + parseHTML: (element) => element.getAttribute("data-src"), renderHTML: (attributes) => ({ - 'data-src': attributes.src, + "data-src": attributes.src, }), }, title: { default: undefined, - parseHTML: (element) => element.getAttribute('data-title'), + parseHTML: (element) => element.getAttribute("data-title"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-title': attributes.title, + "data-title": attributes.title, }), }, width: { - default: '100%', - parseHTML: (element) => element.getAttribute('data-width'), + default: "100%", + parseHTML: (element) => element.getAttribute("data-width"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-width': attributes.width, + "data-width": attributes.width, }), }, size: { default: null, - parseHTML: (element) => element.getAttribute('data-size'), + parseHTML: (element) => element.getAttribute("data-size"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-size': attributes.size, + "data-size": attributes.size, }), }, align: { - default: 'center', - parseHTML: (element) => element.getAttribute('data-align'), + default: "center", + parseHTML: (element) => element.getAttribute("data-align"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-align': attributes.align, + "data-align": attributes.align, }), }, attachmentId: { default: undefined, - parseHTML: (element) => element.getAttribute('data-attachment-id'), + parseHTML: (element) => element.getAttribute("data-attachment-id"), renderHTML: (attributes: ExcalidrawAttributes) => ({ - 'data-attachment-id': attributes.attachmentId, + "data-attachment-id": attributes.attachmentId, }), }, }; @@ -94,13 +94,20 @@ export const Excalidraw = Node.create({ renderHTML({ HTMLAttributes }) { return [ - 'div', + "div", mergeAttributes( - { 'data-type': this.name }, + { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ), - ['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], + [ + "img", + { + src: HTMLAttributes["data-src"], + alt: HTMLAttributes["data-title"], + width: HTMLAttributes["data-width"], + }, + ], ]; }, @@ -110,7 +117,7 @@ export const Excalidraw = Node.create({ (attrs: ExcalidrawAttributes) => ({ commands }) => { return commands.insertContent({ - type: 'excalidraw', + type: "excalidraw", attrs: attrs, }); }, @@ -118,6 +125,9 @@ export const Excalidraw = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 9a759903..d5acdcff 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,127 +1,145 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils"; +import { imageDimensionsFromStream } from "image-dimensions"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("image-upload"); +const findImageNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const ImageUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", placeholderClass); - image.src = src; - placeholder.appendChild(image); - const deco = Decoration.widget(pos + 1, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "image" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleImageUpload = + return result; +}; +const handleImageUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId) => { + async (file, editor, pos, pageId) => { // check if the file is an image const validated = validateFn?.(file); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - // Replace the selection with a placeholder - if (!tr.selection.empty) tr.deleteSelection(); + const objectUrl = URL.createObjectURL(file); + const imageDimensions = await imageDimensionsFromStream(file.stream()); + const placeholderId = generateNodeId(); + const aspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : undefined; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + editor.storage.shared.imagePreviews = + editor.storage.shared.imagePreviews || {}; + editor.storage.shared.imagePreviews[placeholderId] = objectUrl; + + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.image?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + if (!initialPlaceholderNode) return false; + + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; + + if (isEmptyTextBlock) { + // Replace e.g. empty paragraph with the image + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } + + return true; + }; }; + const replacePlaceholderWithImage = (attachment: IAttachment): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return false; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.image?.create({ + // Update the placeholder node with the actual image data + tr.setNodeMarkup(currentPos, undefined, { src: `/api/files/${attachment.id}/${attachment.fileName}`, attachmentId: attachment.id, - title: attachment.fileName, size: attachment.fileSize, + aspectRatio, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + // Remove the placeholder node + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + const disposePreviewFile = () => { + URL.revokeObjectURL(objectUrl); + + if (editor.storage.shared.imagePreviews) { + delete editor.storage.shared.imagePreviews[placeholderId]; + } + }; + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithImage(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithImage(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleImageUpload }; diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 3f7683e4..e0f5053d 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,7 +1,6 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { ImageUploadPlugin } from "./image-upload"; import { mergeAttributes, Range } from "@tiptap/core"; export interface ImageOptions extends DefaultImageOptions { @@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions { export interface ImageAttributes { src?: string; alt?: string; - title?: string; align?: string; attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -22,7 +25,7 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range }, + attributes: ImageAttributes & { pos: number | Range } ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; @@ -90,6 +93,17 @@ export const TiptapImage = Image.extend({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ImageAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -135,14 +149,9 @@ export const TiptapImage = Image.extend({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - ImageUploadPlugin({ - placeholderClass: "image-upload", - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/markdown/index.ts b/packages/editor-ext/src/lib/markdown/index.ts index 96daf9c9..26eb5d48 100644 --- a/packages/editor-ext/src/lib/markdown/index.ts +++ b/packages/editor-ext/src/lib/markdown/index.ts @@ -1 +1,2 @@ export * from "./utils/marked.utils"; +export * from "./utils/turndown.utils"; diff --git a/packages/editor-ext/src/lib/markdown/utils/basename.ts b/packages/editor-ext/src/lib/markdown/utils/basename.ts new file mode 100644 index 00000000..503de941 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/basename.ts @@ -0,0 +1,29 @@ +/** + * Flexible `basename` implementation for node and the browser + * @see https://stackoverflow.com/a/59907288/2228771 + */ +export function getBasename(path: string) { + // make sure the basename is not empty, if string ends with separator + let end = path.length - 1; + while (path[end] === '/' || path[end] === '\\') { + --end; + } + + // support mixing of Win + Unix path separators + const i1 = path.lastIndexOf('/', end); + const i2 = path.lastIndexOf('\\', end); + + let start: number; + if (i1 === -1) { + if (i2 === -1) { + // no separator in the whole thing + return path; + } + start = i2; + } else if (i2 === -1) { + start = i1; + } else { + start = Math.max(i1, i2); + } + return path.substring(start + 1, end + 1); +} diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts new file mode 100644 index 00000000..0e8a9a2d --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.d.ts @@ -0,0 +1,12 @@ +// Map @joplin/turndown types to @types/turndown +declare module "@joplin/turndown" { + import TurndownService from "turndown"; + export = TurndownService; +} + +declare module "@joplin/turndown-plugin-gfm" { + import TurndownService from "turndown"; + export const tables: TurndownService.Plugin; + export const strikethrough: TurndownService.Plugin; + export const highlightedCodeBlock: TurndownService.Plugin; +} diff --git a/apps/server/src/integrations/export/turndown-utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts similarity index 69% rename from apps/server/src/integrations/export/turndown-utils.ts rename to packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index b20e6733..71a2b512 100644 --- a/apps/server/src/integrations/export/turndown-utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -1,22 +1,22 @@ -import * as TurndownService from '@joplin/turndown'; +import * as _TurndownService from '@joplin/turndown'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; -import * as path from 'path'; +import { getBasename } from './basename'; -export function turndown(html: string): string { +// CJS/ESM interop: .default exists in Vite, not in NestJS +const TurndownService = (_TurndownService as any).default || _TurndownService; + +export function htmlToMarkdown(html: string): string { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', hr: '---', bulletListMarker: '-', }); - const tables = TurndownPluginGfm.tables; - const strikethrough = TurndownPluginGfm.strikethrough; - const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock; turndownService.use([ - tables, - strikethrough, - highlightedCodeBlock, + TurndownPluginGfm.tables, + TurndownPluginGfm.strikethrough, + TurndownPluginGfm.highlightedCodeBlock, taskList, callout, preserveDetail, @@ -29,34 +29,33 @@ export function turndown(html: string): string { return turndownService.turndown(html).replaceAll('
', ' '); } -function listParagraph(turndownService: TurndownService) { +function listParagraph(turndownService: _TurndownService) { turndownService.addRule('paragraph', { filter: ['p'], - replacement: (content: any, node: HTMLInputElement) => { + replacement: (content: string, node: HTMLInputElement) => { if (node.parentElement?.nodeName === 'LI') { return content; } - return `\n\n${content}\n\n`; }, }); } -function callout(turndownService: TurndownService) { +function callout(turndownService: _TurndownService) { turndownService.addRule('callout', { filter: function (node: HTMLInputElement) { return ( node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const calloutType = node.getAttribute('data-callout-type'); return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`; }, }); } -function taskList(turndownService: TurndownService) { +function taskList(turndownService: _TurndownService) { turndownService.addRule('taskListItem', { filter: function (node: HTMLInputElement) { return ( @@ -64,32 +63,36 @@ function taskList(turndownService: TurndownService) { node.parentNode.nodeName === 'UL' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string, node: HTMLInputElement) { const checkbox = node.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; const isChecked = checkbox.checked; - + // Process content like regular list items content = content .replace(/^\n+/, '') // remove leading newlines .replace(/\n+$/, '\n') // replace trailing newlines with just a single one .replace(/\n/gm, '\n '); // indent nested content with 2 spaces - + // Create the checkbox prefix const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; - - return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); + + return ( + prefix + + content + + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); }, }); } -function preserveDetail(turndownService: TurndownService) { +function preserveDetail(turndownService: _TurndownService) { turndownService.addRule('preserveDetail', { filter: function (node: HTMLInputElement) { return node.nodeName === 'DETAILS'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const summary = node.querySelector(':scope > summary'); let detailSummary = ''; @@ -111,7 +114,7 @@ function preserveDetail(turndownService: TurndownService) { }); } -function mathInline(turndownService: TurndownService) { +function mathInline(turndownService: _TurndownService) { turndownService.addRule('mathInline', { filter: function (node: HTMLInputElement) { return ( @@ -119,13 +122,13 @@ function mathInline(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathInline' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `$${content}$`; }, }); } -function mathBlock(turndownService: TurndownService) { +function mathBlock(turndownService: _TurndownService) { turndownService.addRule('mathBlock', { filter: function (node: HTMLInputElement) { return ( @@ -133,32 +136,32 @@ function mathBlock(turndownService: TurndownService) { node.getAttribute('data-type') === 'mathBlock' ); }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (content: string) { return `\n$$\n${content}\n$$\n`; }, }); } -function iframeEmbed(turndownService: TurndownService) { +function iframeEmbed(turndownService: _TurndownService) { turndownService.addRule('iframeEmbed', { filter: function (node: HTMLInputElement) { return node.nodeName === 'IFRAME'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src'); return '[' + src + '](' + src + ')'; }, }); } -function video(turndownService: TurndownService) { +function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { return node.tagName === 'VIDEO'; }, - replacement: function (content: any, node: HTMLInputElement) { + replacement: function (_content: string, node: HTMLInputElement) { const src = node.getAttribute('src') || ''; - const name = path.basename(src); + const name = getBasename(src) || src; return '[' + name + '](' + src + ')'; }, }); diff --git a/packages/editor-ext/src/lib/math/math-block.ts b/packages/editor-ext/src/lib/math/math-block.ts index a580596b..cf11e8f8 100644 --- a/packages/editor-ext/src/lib/math/math-block.ts +++ b/packages/editor-ext/src/lib/math/math-block.ts @@ -63,6 +63,9 @@ export const MathBlock = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, diff --git a/packages/editor-ext/src/lib/math/math-inline.ts b/packages/editor-ext/src/lib/math/math-inline.ts index 39c1cd49..3de9d291 100644 --- a/packages/editor-ext/src/lib/math/math-inline.ts +++ b/packages/editor-ext/src/lib/math/math-inline.ts @@ -64,6 +64,9 @@ export const MathInline = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, diff --git a/packages/editor-ext/src/lib/media-utils.ts b/packages/editor-ext/src/lib/media-utils.ts index f05c4264..02a4a1d1 100644 --- a/packages/editor-ext/src/lib/media-utils.ts +++ b/packages/editor-ext/src/lib/media-utils.ts @@ -1,9 +1,8 @@ -import type { EditorView } from "@tiptap/pm/view"; -import { Transaction } from "@tiptap/pm/state"; +import { Editor } from "@tiptap/core"; export type UploadFn = ( file: File, - view: EditorView, + editor: Editor, pos: number, pageId: string, // only applicable to file attachments @@ -14,16 +13,3 @@ export interface MediaUploadOptions { validateFn?: (file: File, allowMedia?: boolean) => void; onUpload: (file: File, pageId: string) => Promise; } - -export function insertTrailingNode( - tr: Transaction, - pos: number, - view: EditorView, -) { - // create trailing node after decoration - // if decoration is at the last node - const currentDocSize = view.state.doc.content.size; - if (pos + 1 === currentDocSize) { - tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create()); - } -} diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts index ca66958f..2326a50a 100644 --- a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -31,6 +31,9 @@ import { import { Node as PMNode, Mark } from "@tiptap/pm/model"; declare module "@tiptap/core" { + interface Storage { + searchAndReplace: SearchAndReplaceStorage; + } interface Commands { search: { /** @@ -184,20 +187,24 @@ const replace = ( if (dispatch) { const tr = state.tr; - + // Get all marks that span the text being replaced const marksSet = new Set(); state.doc.nodesBetween(from, to, (node) => { if (node.isText && node.marks) { - node.marks.forEach(mark => marksSet.add(mark)); + node.marks.forEach((mark) => marksSet.add(mark)); } }); - + const marks = Array.from(marksSet); - // Delete the old text and insert new text with preserved marks + // Delete the old text tr.delete(from, to); - tr.insert(from, state.schema.text(replaceTerm, marks)); + + // Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty) + if (replaceTerm) { + tr.insert(from, state.schema.text(replaceTerm, marks)); + } dispatch(tr); } @@ -215,20 +222,24 @@ const replaceAll = ( // Process replacements in reverse order to avoid position shifting issues for (let i = resultsCopy.length - 1; i >= 0; i -= 1) { const { from, to } = resultsCopy[i]; - + // Get all marks that span the text being replaced const marksSet = new Set(); tr.doc.nodesBetween(from, to, (node) => { if (node.isText && node.marks) { - node.marks.forEach(mark => marksSet.add(mark)); + node.marks.forEach((mark) => marksSet.add(mark)); } }); - + const marks = Array.from(marksSet); - // Delete and insert with preserved marks + // Delete the old text tr.delete(from, to); - tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); + + // Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty) + if (replaceTerm) { + tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); + } } dispatch(tr); @@ -352,10 +363,17 @@ export const SearchAndReplace = Extension.create< // The results will be recalculated by the plugin, but we need to ensure // the index doesn't exceed the new bounds setTimeout(() => { - const newResultsLength = editor.storage.searchAndReplace.results.length; - if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) { + const newResultsLength = + editor.storage.searchAndReplace.results.length; + if ( + newResultsLength > 0 && + editor.storage.searchAndReplace.resultIndex >= newResultsLength + ) { // Keep the same position if possible, otherwise go to the last result - editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1); + editor.storage.searchAndReplace.resultIndex = Math.min( + resultIndex, + newResultsLength - 1, + ); } }, 0); diff --git a/packages/editor-ext/src/lib/shared-storage/index.ts b/packages/editor-ext/src/lib/shared-storage/index.ts new file mode 100644 index 00000000..5b486420 --- /dev/null +++ b/packages/editor-ext/src/lib/shared-storage/index.ts @@ -0,0 +1 @@ +export { SharedStorage } from "./shared-storage"; diff --git a/packages/editor-ext/src/lib/shared-storage/shared-storage.ts b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts new file mode 100644 index 00000000..aa008d45 --- /dev/null +++ b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts @@ -0,0 +1,17 @@ +import { Extension } from "@tiptap/core"; + +declare module "@tiptap/core" { + interface Storage { + shared: Record; + } +} + +const SharedStorage = Extension.create({ + name: "shared", + + addStorage() { + return {}; + }, +}); + +export { SharedStorage }; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 59eb9896..617f43ce 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -44,7 +44,7 @@ export const Subpages = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes, + HTMLAttributes ), ]; }, @@ -63,6 +63,9 @@ export const Subpages = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, }); diff --git a/packages/editor-ext/src/lib/table/cell.ts b/packages/editor-ext/src/lib/table/cell.ts index 63df7dcf..2f693573 100644 --- a/packages/editor-ext/src/lib/table/cell.ts +++ b/packages/editor-ext/src/lib/table/cell.ts @@ -1,4 +1,4 @@ -import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell"; +import { TableCell as TiptapTableCell } from "@tiptap/extension-table"; export const TableCell = TiptapTableCell.extend({ name: "tableCell", diff --git a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts index b4ac2950..1ad57ec1 100644 --- a/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts +++ b/packages/editor-ext/src/lib/table/dnd/dnd-extension.ts @@ -1,7 +1,12 @@ import { Editor, Extension } from "@tiptap/core"; import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state"; import { EditorProps, EditorView } from "@tiptap/pm/view"; -import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils"; +import { + DraggingDOMs, + getDndRelatedDOMs, + getHoveringCell, + HoveringCellInfo, +} from "./utils"; import { getDragOverColumn, getDragOverRow } from "./calc-drag-over"; import { moveColumn, moveRow } from "../utils"; import { PreviewController } from "./preview/preview-controller"; @@ -10,268 +15,302 @@ import { DragHandleController } from "./handle/drag-handle-controller"; import { EmptyImageController } from "./handle/empty-image-controller"; import { AutoScrollController } from "./auto-scroll-controller"; -export const TableDndKey = new PluginKey('table-drag-and-drop') +export const TableDndKey = new PluginKey("table-drag-and-drop"); class TableDragHandlePluginSpec implements PluginSpec { - key = TableDndKey - props: EditorProps> + key = TableDndKey; + props: EditorProps>; - private _colDragHandle: HTMLElement; - private _rowDragHandle: HTMLElement; - private _hoveringCell?: HoveringCellInfo; - private _disposables: (() => void)[] = []; - private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 }; - private _dragging = false; - private _draggingDirection: 'col' | 'row' = 'col'; - private _draggingIndex = -1; - private _droppingIndex = -1; - private _draggingDOMs?: DraggingDOMs | undefined - private _startCoords: { x: number; y: number } = { x: 0, y: 0 }; - private _previewController: PreviewController; - private _dropIndicatorController: DropIndicatorController; - private _dragHandleController: DragHandleController; - private _emptyImageController: EmptyImageController; - private _autoScrollController: AutoScrollController; + private _colDragHandle: HTMLElement; + private _rowDragHandle: HTMLElement; + private _hoveringCell?: HoveringCellInfo; + private _disposables: (() => void)[] = []; + private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 }; + private _dragging = false; + private _draggingDirection: "col" | "row" = "col"; + private _draggingIndex = -1; + private _droppingIndex = -1; + private _draggingDOMs?: DraggingDOMs | undefined; + private _startCoords: { x: number; y: number } = { x: 0, y: 0 }; + private _previewController: PreviewController; + private _dropIndicatorController: DropIndicatorController; + private _dragHandleController: DragHandleController; + private _emptyImageController: EmptyImageController; + private _autoScrollController: AutoScrollController; - constructor(public editor: Editor) { - this.props = { - handleDOMEvents: { - pointerover: this._pointerOver, - } - } + constructor(public editor: Editor) { + this.props = { + handleDOMEvents: { + pointerover: this._pointerOver, + }, + }; - this._dragHandleController = new DragHandleController(); - this._colDragHandle = this._dragHandleController.colDragHandle; - this._rowDragHandle = this._dragHandleController.rowDragHandle; + this._dragHandleController = new DragHandleController(); + this._colDragHandle = this._dragHandleController.colDragHandle; + this._rowDragHandle = this._dragHandleController.rowDragHandle; - this._previewController = new PreviewController(); - this._dropIndicatorController = new DropIndicatorController(); - this._emptyImageController = new EmptyImageController(); + this._previewController = new PreviewController(); + this._dropIndicatorController = new DropIndicatorController(); + this._emptyImageController = new EmptyImageController(); - this._autoScrollController = new AutoScrollController(); + this._autoScrollController = new AutoScrollController(); - this._bindDragEvents(); + this._bindDragEvents(); + } + + view = () => { + const wrapper = this.editor.options.element; + //@ts-ignore + wrapper.appendChild(this._colDragHandle); + //@ts-ignore + wrapper.appendChild(this._rowDragHandle); + //@ts-ignore + wrapper.appendChild(this._previewController.previewRoot); + //@ts-ignore + wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot); + + return { + update: this.update, + destroy: this.destroy, + }; + }; + + update = () => {}; + + destroy = () => { + if (!this.editor.isDestroyed) return; + this._dragHandleController.destroy(); + this._emptyImageController.destroy(); + this._previewController.destroy(); + this._dropIndicatorController.destroy(); + this._autoScrollController.stop(); + + this._disposables.forEach((disposable) => disposable()); + }; + + private _pointerOver = (view: EditorView, event: PointerEvent) => { + if (this._dragging) return; + + // Don't show drag handles in readonly mode + if (!this.editor.isEditable) { + this._dragHandleController.hide(); + return; } - view = () => { - const wrapper = this.editor.options.element; - wrapper.appendChild(this._colDragHandle) - wrapper.appendChild(this._rowDragHandle) - wrapper.appendChild(this._previewController.previewRoot) - wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot) + const hoveringCell = getHoveringCell(view, event); + this._hoveringCell = hoveringCell; + if (!hoveringCell) { + this._dragHandleController.hide(); + } else { + this._dragHandleController.show(this.editor, hoveringCell); + } + }; - return { - update: this.update, - destroy: this.destroy, - } + private _onDragColStart = (event: DragEvent) => { + this._onDragStart(event, "col"); + }; + + private _onDraggingCol = (event: DragEvent) => { + const draggingDOMs = this._draggingDOMs; + if (!draggingDOMs) return; + + this._draggingCoords = { x: event.clientX, y: event.clientY }; + this._previewController.onDragging( + draggingDOMs, + this._draggingCoords.x, + this._draggingCoords.y, + "col", + ); + + this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs); + + const direction = + this._startCoords.x > this._draggingCoords.x ? "left" : "right"; + const dragOverColumn = getDragOverColumn( + draggingDOMs.table, + this._draggingCoords.x, + ); + if (!dragOverColumn) return; + + const [col, index] = dragOverColumn; + this._droppingIndex = index; + this._dropIndicatorController.onDragging(col, direction, "col"); + }; + + private _onDragRowStart = (event: DragEvent) => { + this._onDragStart(event, "row"); + }; + + private _onDraggingRow = (event: DragEvent) => { + const draggingDOMs = this._draggingDOMs; + if (!draggingDOMs) return; + + this._draggingCoords = { x: event.clientX, y: event.clientY }; + this._previewController.onDragging( + draggingDOMs, + this._draggingCoords.x, + this._draggingCoords.y, + "row", + ); + + this._autoScrollController.checkYAutoScroll(event.clientY); + + const direction = + this._startCoords.y > this._draggingCoords.y ? "up" : "down"; + const dragOverRow = getDragOverRow( + draggingDOMs.table, + this._draggingCoords.y, + ); + if (!dragOverRow) return; + + const [row, index] = dragOverRow; + this._droppingIndex = index; + this._dropIndicatorController.onDragging(row, direction, "row"); + }; + + private _onDragEnd = () => { + this._dragging = false; + this._draggingIndex = -1; + this._droppingIndex = -1; + this._startCoords = { x: 0, y: 0 }; + this._autoScrollController.stop(); + this._dropIndicatorController.onDragEnd(); + this._previewController.onDragEnd(); + }; + + private _bindDragEvents = () => { + this._colDragHandle.addEventListener("dragstart", this._onDragColStart); + this._disposables.push(() => { + this._colDragHandle.removeEventListener( + "dragstart", + this._onDragColStart, + ); + }); + + this._colDragHandle.addEventListener("dragend", this._onDragEnd); + this._disposables.push(() => { + this._colDragHandle.removeEventListener("dragend", this._onDragEnd); + }); + + this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart); + this._disposables.push(() => { + this._rowDragHandle.removeEventListener( + "dragstart", + this._onDragRowStart, + ); + }); + + this._rowDragHandle.addEventListener("dragend", this._onDragEnd); + this._disposables.push(() => { + this._rowDragHandle.removeEventListener("dragend", this._onDragEnd); + }); + + const ownerDocument = this.editor.view.dom?.ownerDocument; + if (ownerDocument) { + // To make `drop` event work, we need to prevent the default behavior of the + // `dragover` event for drop zone. Here we set the whole document as the + // drop zone so that even the mouse moves outside the editor, the `drop` + // event will still be triggered. + ownerDocument.addEventListener("drop", this._onDrop); + ownerDocument.addEventListener("dragover", this._onDrag); + this._disposables.push(() => { + ownerDocument.removeEventListener("drop", this._onDrop); + ownerDocument.removeEventListener("dragover", this._onDrag); + }); + } + }; + + private _onDragStart = (event: DragEvent, type: "col" | "row") => { + const dataTransfer = event.dataTransfer; + if (dataTransfer) { + dataTransfer.effectAllowed = "move"; + this._emptyImageController.hideDragImage(dataTransfer); + } + this._dragging = true; + this._draggingDirection = type; + this._startCoords = { x: event.clientX, y: event.clientY }; + const draggingIndex = + (type === "col" + ? this._hoveringCell?.colIndex + : this._hoveringCell?.rowIndex) ?? 0; + + this._draggingIndex = draggingIndex; + + const relatedDoms = getDndRelatedDOMs( + this.editor.view, + this._hoveringCell?.cellPos, + draggingIndex, + type, + ); + this._draggingDOMs = relatedDoms; + + const index = + type === "col" + ? this._hoveringCell?.colIndex + : this._hoveringCell?.rowIndex; + + this._previewController.onDragStart(relatedDoms, index, type); + this._dropIndicatorController.onDragStart(relatedDoms, type); + }; + + private _onDrag = (event: DragEvent) => { + event.preventDefault(); + if (!this._dragging) return; + if (this._draggingDirection === "col") { + this._onDraggingCol(event); + } else { + this._onDraggingRow(event); + } + }; + + private _onDrop = () => { + if (!this._dragging) return; + const direction = this._draggingDirection; + const from = this._draggingIndex; + const to = this._droppingIndex; + const tr = this.editor.state.tr; + const pos = this.editor.state.selection.from; + + if (direction === "col") { + const canMove = moveColumn({ + tr, + originIndex: from, + targetIndex: to, + select: true, + pos, + }); + if (canMove) { + this.editor.view.dispatch(tr); + } + + return; } - update = () => {} + if (direction === "row") { + const canMove = moveRow({ + tr, + originIndex: from, + targetIndex: to, + select: true, + pos, + }); + if (canMove) { + this.editor.view.dispatch(tr); + } - destroy = () => { - if (!this.editor.isDestroyed) return; - this._dragHandleController.destroy(); - this._emptyImageController.destroy(); - this._previewController.destroy(); - this._dropIndicatorController.destroy(); - this._autoScrollController.stop(); - - this._disposables.forEach(disposable => disposable()); - } - - private _pointerOver = (view: EditorView, event: PointerEvent) => { - if (this._dragging) return; - - // Don't show drag handles in readonly mode - if (!this.editor.isEditable) { - this._dragHandleController.hide(); - return; - } - - const hoveringCell = getHoveringCell(view, event) - this._hoveringCell = hoveringCell; - if (!hoveringCell) { - this._dragHandleController.hide(); - } else { - this._dragHandleController.show(this.editor, hoveringCell); - } - } - - private _onDragColStart = (event: DragEvent) => { - this._onDragStart(event, 'col'); - } - - private _onDraggingCol = (event: DragEvent) => { - const draggingDOMs = this._draggingDOMs; - if (!draggingDOMs) return; - - this._draggingCoords = { x: event.clientX, y: event.clientY }; - this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'col'); - - this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs); - - const direction = this._startCoords.x > this._draggingCoords.x ? 'left' : 'right'; - const dragOverColumn = getDragOverColumn(draggingDOMs.table, this._draggingCoords.x); - if (!dragOverColumn) return; - - const [col, index] = dragOverColumn; - this._droppingIndex = index; - this._dropIndicatorController.onDragging(col, direction, 'col'); - } - - private _onDragRowStart = (event: DragEvent) => { - this._onDragStart(event, 'row'); - } - - private _onDraggingRow = (event: DragEvent) => { - const draggingDOMs = this._draggingDOMs; - if (!draggingDOMs) return; - - this._draggingCoords = { x: event.clientX, y: event.clientY }; - this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'row'); - - this._autoScrollController.checkYAutoScroll(event.clientY); - - const direction = this._startCoords.y > this._draggingCoords.y ? 'up' : 'down'; - const dragOverRow = getDragOverRow(draggingDOMs.table, this._draggingCoords.y); - if (!dragOverRow) return; - - const [row, index] = dragOverRow; - this._droppingIndex = index; - this._dropIndicatorController.onDragging(row, direction, 'row'); - } - - private _onDragEnd = () => { - this._dragging = false; - this._draggingIndex = -1; - this._droppingIndex = -1; - this._startCoords = { x: 0, y: 0 }; - this._autoScrollController.stop(); - this._dropIndicatorController.onDragEnd(); - this._previewController.onDragEnd(); - } - - private _bindDragEvents = () => { - this._colDragHandle.addEventListener('dragstart', this._onDragColStart); - this._disposables.push(() => { - this._colDragHandle.removeEventListener('dragstart', this._onDragColStart); - }) - - this._colDragHandle.addEventListener('dragend', this._onDragEnd); - this._disposables.push(() => { - this._colDragHandle.removeEventListener('dragend', this._onDragEnd); - }) - - this._rowDragHandle.addEventListener('dragstart', this._onDragRowStart); - this._disposables.push(() => { - this._rowDragHandle.removeEventListener('dragstart', this._onDragRowStart); - }) - - this._rowDragHandle.addEventListener('dragend', this._onDragEnd); - this._disposables.push(() => { - this._rowDragHandle.removeEventListener('dragend', this._onDragEnd); - }) - - const ownerDocument = this.editor.view.dom?.ownerDocument - if (ownerDocument) { - // To make `drop` event work, we need to prevent the default behavior of the - // `dragover` event for drop zone. Here we set the whole document as the - // drop zone so that even the mouse moves outside the editor, the `drop` - // event will still be triggered. - ownerDocument.addEventListener('drop', this._onDrop); - ownerDocument.addEventListener('dragover', this._onDrag); - this._disposables.push(() => { - ownerDocument.removeEventListener('drop', this._onDrop); - ownerDocument.removeEventListener('dragover', this._onDrag); - }); - } - } - - private _onDragStart = (event: DragEvent, type: 'col' | 'row') => { - const dataTransfer = event.dataTransfer; - if (dataTransfer) { - dataTransfer.effectAllowed = 'move'; - this._emptyImageController.hideDragImage(dataTransfer); - } - this._dragging = true; - this._draggingDirection = type; - this._startCoords = { x: event.clientX, y: event.clientY }; - const draggingIndex = (type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex) ?? 0; - - this._draggingIndex = draggingIndex; - - const relatedDoms = getDndRelatedDOMs( - this.editor.view, - this._hoveringCell?.cellPos, - draggingIndex, - type - ) - this._draggingDOMs = relatedDoms; - - const index = type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex; - - this._previewController.onDragStart(relatedDoms, index, type); - this._dropIndicatorController.onDragStart(relatedDoms, type); - } - - private _onDrag = (event: DragEvent) => { - event.preventDefault() - if (!this._dragging) return; - if (this._draggingDirection === 'col') { - this._onDraggingCol(event); - } else { - this._onDraggingRow(event); - } - } - - private _onDrop = () => { - if (!this._dragging) return; - const direction = this._draggingDirection; - const from = this._draggingIndex; - const to = this._droppingIndex; - const tr = this.editor.state.tr; - const pos = this.editor.state.selection.from; - - if (direction === 'col') { - const canMove = moveColumn({ - tr, - originIndex: from, - targetIndex: to, - select: true, - pos, - }) - if (canMove) { - this.editor.view.dispatch(tr); - } - - return; - } - - if (direction === 'row') { - const canMove = moveRow({ - tr, - originIndex: from, - targetIndex: to, - select: true, - pos, - }) - if (canMove) { - this.editor.view.dispatch(tr); - } - - return; - } + return; } + }; } export const TableDndExtension = Extension.create({ - name: 'table-drag-and-drop', - addProseMirrorPlugins() { - const editor = this.editor + name: "table-drag-and-drop", + addProseMirrorPlugins() { + const editor = this.editor; - const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor) - const dragHandlePlugin = new Plugin(dragHandlePluginSpec) + const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor); + const dragHandlePlugin = new Plugin(dragHandlePluginSpec); - return [dragHandlePlugin] - } -}) + return [dragHandlePlugin]; + }, +}); diff --git a/packages/editor-ext/src/lib/table/header.ts b/packages/editor-ext/src/lib/table/header.ts index 501f089d..77ab02f1 100644 --- a/packages/editor-ext/src/lib/table/header.ts +++ b/packages/editor-ext/src/lib/table/header.ts @@ -1,4 +1,4 @@ -import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header"; +import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table"; export const TableHeader = TiptapTableHeader.extend({ name: "tableHeader", diff --git a/packages/editor-ext/src/lib/table/row.ts b/packages/editor-ext/src/lib/table/row.ts index 3aa67dcd..7839afdf 100644 --- a/packages/editor-ext/src/lib/table/row.ts +++ b/packages/editor-ext/src/lib/table/row.ts @@ -1,6 +1,5 @@ -import TiptapTableRow from "@tiptap/extension-table-row"; +import { TableRow as TiptapTableRow } from "@tiptap/extension-table"; export const TableRow = TiptapTableRow.extend({ - allowGapCursor: false, content: "(tableCell | tableHeader)*", }); diff --git a/packages/editor-ext/src/lib/table/table.ts b/packages/editor-ext/src/lib/table/table.ts index 87053832..f1436c28 100644 --- a/packages/editor-ext/src/lib/table/table.ts +++ b/packages/editor-ext/src/lib/table/table.ts @@ -1,4 +1,4 @@ -import Table from "@tiptap/extension-table"; +import { Table } from "@tiptap/extension-table"; import { Editor } from "@tiptap/core"; import { DOMOutputSpec } from "@tiptap/pm/model"; diff --git a/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts deleted file mode 100644 index d193e8b3..00000000 --- a/packages/editor-ext/src/lib/unique-id/helpers/findDuplicates.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { removeDuplicates } from './removeDuplicates.js' - -/** - * Returns a list of duplicated items within an array. - */ -export function findDuplicates(items: any[]): any[] { - const filtered = items.filter((el, index) => items.indexOf(el) !== index) - const duplicates = removeDuplicates(filtered) - - return duplicates -} diff --git a/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts b/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts deleted file mode 100644 index 2bae38fd..00000000 --- a/packages/editor-ext/src/lib/unique-id/helpers/removeDuplicates.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Removes duplicated values within an array. - * Supports numbers, strings and objects. - */ -export function removeDuplicates(array: T[], by = JSON.stringify): T[] { - const seen: Record = {} - - return array.filter(item => { - const key = by(item) - - return Object.prototype.hasOwnProperty.call(seen, key) - ? false - : (seen[key] = true) - }) -} diff --git a/packages/editor-ext/src/lib/unique-id/unique-id.ts b/packages/editor-ext/src/lib/unique-id/unique-id.ts index 6ecf15f0..8436cbd2 100644 --- a/packages/editor-ext/src/lib/unique-id/unique-id.ts +++ b/packages/editor-ext/src/lib/unique-id/unique-id.ts @@ -1,386 +1,11 @@ -import { - combineTransactionSteps, - Extension, - findChildren, - findChildrenInRange, - getChangedRanges, -} from "@tiptap/core"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { Fragment, Slice } from "@tiptap/pm/model"; -import type { Transaction } from "@tiptap/pm/state"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; - -import { findDuplicates } from "./helpers/findDuplicates.js"; import { generateNodeId } from "../utils"; +import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id"; -export type UniqueIDGenerationContext = { - node: ProseMirrorNode; - pos: number; -}; - -export interface UniqueIDOptions { - /** - * The name of the attribute to add the unique ID to. - * @default "id" - */ - attributeName: string; - /** - * The types of nodes to add unique IDs to. - * @default [] - */ - types: string[]; - /** - * The function that generates the unique ID. By default, a UUID v4 is - * generated. However, you can provide your own function to generate the - * unique ID based on the node type and the position. - */ - generateID: (ctx: UniqueIDGenerationContext) => any; - /** - * Ignore some mutations, for example applied from other users through the collaboration plugin. - * - * @default null - */ - filterTransaction: ((transaction: Transaction) => boolean) | null; - /** - * Whether to update the document by adding unique IDs to the nodes. Set this - * property to `false` if the document is in `readonly` mode, is immutable, or - * you don't want it to be modified. - * - * @default true - */ - updateDocument: boolean; -} - -export const UniqueID = Extension.create({ - name: "uniqueID", - - // we’ll set a very high priority to make sure this runs first - // and is compatible with `appendTransaction` hooks of other extensions - priority: 10000, - +export const UniqueID = TiptapUniqueID.extend({ addOptions() { return { - attributeName: "id", - types: [], + ...this.parent?.(), generateID: () => generateNodeId(), - filterTransaction: null, - updateDocument: true, }; }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - [this.options.attributeName]: { - default: null, - parseHTML: (element) => - element.getAttribute(`data-${this.options.attributeName}`), - renderHTML: (attributes) => { - if (!attributes[this.options.attributeName]) { - return {}; - } - - return { - [`data-${this.options.attributeName}`]: - attributes[this.options.attributeName], - }; - }, - }, - }, - }, - ]; - }, - - // check initial content for missing ids - onCreate() { - if (!this.options.updateDocument) { - return; - } - - const collaboration = this.editor.extensionManager.extensions.find( - (ext) => ext.name === "collaboration", - ); - const collaborationCursor = this.editor.extensionManager.extensions.find( - (ext) => ext.name === "collaborationCursor", - ); - - const collabExtensions = [collaboration, collaborationCursor].filter( - Boolean, - ); - const collab = collabExtensions.find((ext) => ext?.options?.provider); - const provider = collab?.options?.provider; - - const createIds = () => { - const { view, state } = this.editor; - const { tr, doc } = state; - const { types, attributeName, generateID } = this.options; - const nodesWithoutId = findChildren(doc, (node) => { - return ( - types.includes(node.type.name) && node.attrs[attributeName] === null - ); - }); - - nodesWithoutId.forEach(({ node, pos }) => { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - }); - - tr.setMeta("addToHistory", false); - - view.dispatch(tr); - - if (provider) { - provider.off("synced", createIds); - } - }; - - /** - * We need to handle collaboration a bit different here - * because we can't automatically add IDs when the provider is not yet synced - * otherwise we end up with empty paragraphs - */ - if (collab) { - if (!provider) { - return createIds(); - } - - provider.on("synced", createIds); - } else { - return createIds(); - } - }, - - addProseMirrorPlugins() { - if (!this.options.updateDocument) { - return []; - } - - let dragSourceElement: Element | null = null; - let transformPasted = false; - - return [ - new Plugin({ - key: new PluginKey("uniqueID"), - - appendTransaction: (transactions, oldState, newState) => { - const hasDocChanges = - transactions.some((transaction) => transaction.docChanged) && - !oldState.doc.eq(newState.doc); - const filterTransactions = - this.options.filterTransaction && - transactions.some((tr) => !this.options.filterTransaction?.(tr)); - - const isCollabTransaction = transactions.find((tr) => - tr.getMeta("y-sync$"), - ); - - if (isCollabTransaction) { - return; - } - - if (!hasDocChanges || filterTransactions) { - return; - } - - const { tr } = newState; - - const { types, attributeName, generateID } = this.options; - const transform = combineTransactionSteps( - oldState.doc, - transactions as Transaction[], - ); - const { mapping } = transform; - - // get changed ranges based on the old state - const changes = getChangedRanges(transform); - - changes.forEach(({ newRange }) => { - const newNodes = findChildrenInRange( - newState.doc, - newRange, - (node) => { - return types.includes(node.type.name); - }, - ); - - const newIds = newNodes - .map(({ node }) => node.attrs[attributeName]) - .filter((id) => id !== null); - - newNodes.forEach(({ node, pos }, i) => { - // instead of checking `node.attrs[attributeName]` directly - // we look at the current state of the node within `tr.doc`. - // this helps to prevent adding new ids to the same node - // if the node changed multiple times within one transaction - const id = tr.doc.nodeAt(pos)?.attrs[attributeName]; - - if (id === null) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - - return; - } - - const nextNode = newNodes[i + 1]; - - if (nextNode && node.content.size === 0) { - tr.setNodeMarkup(nextNode.pos, undefined, { - ...nextNode.node.attrs, - [attributeName]: id, - }); - newIds[i + 1] = id; - - if (nextNode.node.attrs[attributeName]) { - return; - } - - const generatedId = generateID({ node, pos }); - - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generatedId, - }); - newIds[i] = generatedId; - - return tr; - } - - const duplicatedNewIds = findDuplicates(newIds); - - // check if the node doesn’t exist in the old state - const { deleted } = mapping.invert().mapResult(pos); - - const newNode = deleted && duplicatedNewIds.includes(id); - - if (newNode) { - tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - [attributeName]: generateID({ node, pos }), - }); - } - }); - }); - - if (!tr.steps.length) { - return; - } - - // `tr.setNodeMarkup` resets the stored marks - // so we'll restore them if they exist - tr.setStoredMarks(newState.tr.storedMarks); - - // Mark this transaction as coming from UniqueID - // to prevent infinite loops with other extensions (e.g., TrailingNode) - tr.setMeta("__uniqueIDTransaction", true); - - return tr; - }, - - // we register a global drag handler to track the current drag source element - view(view) { - const handleDragstart = (event: DragEvent) => { - dragSourceElement = view.dom.parentElement?.contains( - event.target as Element, - ) - ? view.dom.parentElement - : null; - }; - - window.addEventListener("dragstart", handleDragstart); - - return { - destroy() { - window.removeEventListener("dragstart", handleDragstart); - }, - }; - }, - - props: { - // `handleDOMEvents` is called before `transformPasted` - // so we can do some checks before - handleDOMEvents: { - // only create new ids for dropped content - // or dropped content while holding `alt` - // or content is dragged from another editor - drop: (view, event) => { - if ( - dragSourceElement !== view.dom.parentElement || - event.dataTransfer?.effectAllowed === "copyMove" || - event.dataTransfer?.effectAllowed === "copy" - ) { - dragSourceElement = null; - transformPasted = true; - } - - return false; - }, - // always create new ids on pasted content - paste: () => { - transformPasted = true; - - return false; - }, - }, - - // we’ll remove ids for every pasted node - // so we can create a new one within `appendTransaction` - transformPasted: (slice) => { - if (!transformPasted) { - return slice; - } - - const { types, attributeName } = this.options; - const removeId = (fragment: Fragment): Fragment => { - const list: ProseMirrorNode[] = []; - - fragment.forEach((node) => { - // don’t touch text nodes - if (node.isText) { - list.push(node); - - return; - } - - // check for any other child nodes - if (!types.includes(node.type.name)) { - list.push(node.copy(removeId(node.content))); - - return; - } - - // remove id - const nodeWithoutId = node.type.create( - { - ...node.attrs, - [attributeName]: null, - }, - removeId(node.content), - node.marks, - ); - - list.push(nodeWithoutId); - }); - - return Fragment.from(list); - }; - - // reset check - transformPasted = false; - - return new Slice( - removeId(slice.content), - slice.openStart, - slice.openEnd, - ); - }, - }, - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/utils.ts b/packages/editor-ext/src/lib/utils.ts index e4e7fda4..350ab3bb 100644 --- a/packages/editor-ext/src/lib/utils.ts +++ b/packages/editor-ext/src/lib/utils.ts @@ -1,6 +1,6 @@ -// @ts-nocheck import { Editor, findParentNode, isTextSelection } from "@tiptap/core"; -import { Selection, Transaction } from "@tiptap/pm/state"; +import { EditorState, Selection, Transaction } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { Node, ResolvedPos } from "@tiptap/pm/model"; import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url"; @@ -287,11 +287,7 @@ export const isColumnGripSelected = ({ const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; - if ( - !editor.isActive("table") || - !node || - isTableSelected(state.selection) - ) { + if (!editor.isActive("table") || !node || isTableSelected(state.selection)) { return false; } @@ -324,11 +320,7 @@ export const isRowGripSelected = ({ const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; - if ( - !editor.isActive(Table.name) || - !node || - isTableSelected(state.selection) - ) { + if (!editor.isActive("table") || !node || isTableSelected(state.selection)) { return false; } diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts index 1e976ecc..404cf99e 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -1,132 +1,169 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; +import { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("video-upload"); +const findVideoNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const VideoUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, src } = action.add; + doc.descendants((node, pos) => { + if (result) return false; - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "video-placeholder"); - const video = document.createElement("video"); - video.setAttribute("class", placeholderClass); - video.src = src; - placeholder.appendChild(video); - const deco = Decoration.widget(pos + 1, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + if ( + node.type.name === "video" && + node.attrs.placeholder?.id === placeholderId + ) { + result = { node, pos }; + return false; + } + + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} + return result; +}; +const getVideoDimensions = ( + url: string, +): Promise< + { width: number; height: number; aspectRatio: number } | undefined +> => { + return new Promise< + { width: number; height: number; aspectRatio: number } | undefined + >((resolve) => { + const video = document.createElement("video"); -export const handleVideoUpload = + video.preload = "metadata"; + video.onloadedmetadata = () => { + const width = video.videoWidth; + const height = video.videoHeight; + const aspectRatio = height > 0 ? width / height : 1; + + resolve({ width, height, aspectRatio }); + }; + video.onerror = () => { + resolve(undefined); + }; + video.src = url; + }); +}; +const handleVideoUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId) => { - // check if the file is an image + async (file, editor, pos, pageId) => { + // check if the file is valid const validated = validateFn?.(file); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder + const objectUrl = URL.createObjectURL(file); + const videoDimensions = await getVideoDimensions(objectUrl); + const placeholderId = generateNodeId(); + const aspectRatio = videoDimensions.aspectRatio; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + let placeholderInserted = false; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + editor.storage.shared.videoPreviews = + editor.storage.shared.videoPreviews || {}; + editor.storage.shared.videoPreviews[placeholderId] = objectUrl; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.video?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + if (!initialPlaceholderNode) return false; + + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; + + if (isEmptyTextBlock) { + // Replace e.g. empty paragraph with the video + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } + + return true; + }; }; + const replacePlaceholderWithVideo = (attachment: IAttachment): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.video?.create({ + // Update the placeholder node with the actual video data + tr.setNodeMarkup(currentPos, undefined, { src: `/api/files/${attachment.id}/${attachment.fileName}`, attachmentId: attachment.id, title: attachment.fileName, size: attachment.fileSize, + aspectRatio, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + return true; + }; + }; + const removePlaceholder = (): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return false; + + tr.delete(currentPos, currentPos + 2); + + return true; + }; + }; + + // Only show the placeholder if the upload takes more than 250ms + const insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + const disposePreviewFile = () => { + URL.revokeObjectURL(objectUrl); + + if (editor.storage.shared.videoPreviews) { + delete editor.storage.shared.videoPreviews[placeholderId]; + } + }; + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithVideo(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithVideo(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleVideoUpload }; diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 6f28e7c0..c3c6ab3e 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,6 +1,5 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; -import { VideoUploadPlugin } from "./video-upload"; -import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core"; +import { Range, Node } from "@tiptap/core"; export interface VideoOptions { view: any; @@ -8,11 +7,15 @@ export interface VideoOptions { } export interface VideoAttributes { src?: string; - title?: string; align?: string; attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -20,7 +23,7 @@ declare module "@tiptap/core" { videoBlock: { setVideo: (attributes: VideoAttributes) => ReturnType; setVideoAt: ( - attributes: VideoAttributes & { pos: number | Range }, + attributes: VideoAttributes & { pos: number | Range } ) => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoWidth: (width: number) => ReturnType; @@ -81,15 +84,26 @@ export const TiptapVideo = Node.create({ "data-align": attributes.align, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: VideoAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, parseHTML() { return [ { - tag: 'video', + tag: "video", }, - ] + ]; }, renderHTML({ HTMLAttributes }) { @@ -126,14 +140,9 @@ export const TiptapVideo = Node.create({ }, addNodeView() { + // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - VideoUploadPlugin({ - placeholderClass: "video-upload", - }), - ]; - }, }); diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index efbfcd61..974fea06 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -8,6 +8,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2022", + "jsx": "react-jsx", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d148236b..9bef9483 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,26 +22,23 @@ importers: specifier: ^7.1.0 version: 7.1.0 '@casl/ability': - specifier: ^6.7.5 - version: 6.7.5 + specifier: 6.8.0 + version: 6.8.0 '@docmost/editor-ext': specifier: workspace:* version: link:packages/editor-ext '@floating-ui/dom': specifier: ^1.7.3 version: 1.7.3 - '@hocuspocus/extension-redis': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/provider': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/server': - specifier: ^2.15.3 - version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) '@hocuspocus/transformer': - specifier: ^2.15.3 - version: 2.15.3(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27) + specifier: 3.4.3 + version: 3.4.3(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) '@joplin/turndown': specifier: ^4.0.74 version: 4.0.74 @@ -52,107 +49,86 @@ importers: specifier: 1.1.0 version: 1.1.0 '@tiptap/core': - specifier: 2.27.1 - version: 2.27.1(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/pm@3.17.1) '@tiptap/extension-code-block': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-code-block-lowlight': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-collaboration': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) - '@tiptap/extension-collaboration-cursor': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-collaboration-caret': + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)) '@tiptap/extension-color': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))) '@tiptap/extension-document': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-heading': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-highlight': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-history': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-image': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-link': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-list-item': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-list-keymap': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-placeholder': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) '@tiptap/extension-subscript': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-superscript': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-table': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-table-cell': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-table-header': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-table-row': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-task-item': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-task-list': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-text': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-text-align': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-text-style': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/extension-typography': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-underline': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-unique-id': + specifier: ^3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@tiptap/extension-youtube': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) '@tiptap/html': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0) '@tiptap/pm': - specifier: 2.27.1 - version: 2.27.1 + specifier: 3.17.1 + version: 3.17.1 '@tiptap/react': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 3.17.1 + version: 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': - specifier: 2.27.1 - version: 2.27.1 + specifier: 3.17.1 + version: 3.17.1 '@tiptap/suggestion': - specifier: 2.27.1 - version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) + specifier: 3.17.1 + version: 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) '@types/qrcode': specifier: ^1.5.5 version: 1.5.5 @@ -171,6 +147,12 @@ importers: fractional-indexing-jittered: specifier: ^1.0.0 version: 1.0.0 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + image-dimensions: + specifier: ^2.5.0 + version: 2.5.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -194,13 +176,13 @@ importers: version: 11.1.0 y-indexeddb: specifier: ^9.0.12 - version: 9.0.12(yjs@13.6.27) + version: 9.0.12(yjs@13.6.29) y-prosemirror: specifier: 1.3.7 - version: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) yjs: - specifier: ^13.6.27 - version: 13.6.27 + specifier: ^13.6.29 + version: 13.6.29 devDependencies: '@nx/js': specifier: 20.4.5 @@ -208,6 +190,9 @@ importers: '@types/bytes': specifier: ^3.1.5 version: 3.1.5 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -225,7 +210,7 @@ importers: dependencies: '@casl/react': specifier: ^4.0.0 - version: 4.0.0(@casl/ability@6.7.5)(react@18.3.1) + version: 4.0.0(@casl/ability@6.8.0)(react@18.3.1) '@docmost/editor-ext': specifier: workspace:* version: link:../../packages/editor-ext @@ -265,9 +250,6 @@ importers: '@tanstack/react-query': specifier: ^5.90.17 version: 5.90.17(react@18.3.1) - '@tiptap/extension-character-count': - specifier: ^2.27.1 - version: 2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) alfaaz: specifier: ^1.1.0 version: 1.1.0 @@ -355,9 +337,6 @@ importers: socket.io-client: specifier: ^4.8.3 version: 4.8.3 - tippy.js: - specifier: ^6.3.7 - version: 6.3.7 tiptap-extension-global-drag-handle: specifier: ^0.1.18 version: 0.1.18 @@ -566,21 +545,33 @@ importers: kysely-migration-cli: specifier: ^0.4.2 version: 0.4.2 + kysely-postgres-js: + specifier: ^3.0.0 + version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) ldapts: specifier: ^7.4.0 version: 7.4.0 + lib0: + specifier: ^0.2.117 + version: 0.2.117 mammoth: specifier: ^1.11.0 version: 1.11.0 mime-types: specifier: ^2.1.35 version: 2.1.35 + msgpackr: + specifier: ^1.11.8 + version: 1.11.8 nanoid: specifier: 3.3.11 version: 3.3.11 nestjs-kysely: specifier: ^1.2.0 version: 1.2.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(kysely@0.28.2)(reflect-metadata@0.2.2) + nestjs-pino: + specifier: ^4.5.0 + version: 4.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2) nodemailer: specifier: ^7.0.12 version: 7.0.12 @@ -602,15 +593,21 @@ importers: pdfjs-dist: specifier: ^5.4.394 version: 5.4.394 - pg: - specifier: ^8.16.3 - version: 8.16.3 pg-tsquery: specifier: ^8.4.2 version: 8.4.2 pgvector: specifier: ^0.2.1 version: 0.2.1 + pino-http: + specifier: ^11.0.0 + version: 11.0.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + postgres: + specifier: ^3.4.8 + version: 3.4.8 postmark: specifier: ^4.0.5 version: 4.0.5 @@ -638,6 +635,9 @@ importers: tmp-promise: specifier: ^3.0.3 version: 3.0.3 + tseep: + specifier: ^1.3.1 + version: 1.3.1 typesense: specifier: ^2.1.0 version: 2.1.0(@babel/runtime@7.25.6) @@ -687,9 +687,6 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 - '@types/pg': - specifier: ^8.11.11 - version: 8.11.11 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 @@ -1869,8 +1866,8 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} - '@casl/ability@6.7.5': - resolution: {integrity: sha512-NaOHPi9JMn8Kesh+GRkjNKAYkl4q8qMFAlqw7w2yrE+cBQZSbV9GkBGKvgzs3CdzEc5Yl1cn3JwDxxbBN5gjog==} + '@casl/ability@6.8.0': + resolution: {integrity: sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==} '@casl/react@4.0.0': resolution: {integrity: sha512-ovmI4JfNw7TfVVV+XhAJ//gXgMEkkPJU6YBWFVFZGa8Oikdh8Qxr/sdXcqj71QWEHAGN7aSKMtBE0MZylPUVsg==} @@ -2399,32 +2396,26 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@hocuspocus/common@2.15.3': - resolution: {integrity: sha512-Rzh1HF0a2o/tf90A3w2XNdXd9Ym3aQzMDfD3lAUONCX9B9QOdqdyiORrj6M25QEaJrEIbXFy8LtAFcL0wRdWzA==} + '@hocuspocus/common@3.4.3': + resolution: {integrity: sha512-wnBBO9sWcVAoUPEXN1qO+zk3HaEF9VTemxB6kjuuH6e1dHnD0v12m4P4X1wiZVhmMIX/PMl/fu3MGtYWQJz8gA==} - '@hocuspocus/extension-redis@2.15.3': - resolution: {integrity: sha512-gKeiiuQcAoRYb+QK9vyIczRrjNy8NW6ky+oyVv7raMcaizfFxeWP3TaAHPyC2pjGKfXsqN2m3YM0GbBGZfMiCg==} + '@hocuspocus/provider@3.4.3': + resolution: {integrity: sha512-zt+UgVXGsEQrqnDZgavc2PT9yKJjmVjV+5YxvhlmFVFLVORqawT4l601aKmLPhvyK97un4ZApZ5rso8iO6crWg==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/provider@2.15.3': - resolution: {integrity: sha512-oadN05m+KL4ylNKVo5YspNG4MXkT2Y+FUFzrgigpQeTjQibkPUwCNmUnkUxMgrGRgxb+O0lJCfirFIJMxedctA==} + '@hocuspocus/server@3.4.3': + resolution: {integrity: sha512-a9bqAXUMBo9YBeuzqNf9C3eVbu1RIWUrtmFMGq+ZssQr3Jugt/5PCkZskgqhJNvPkyTARHcUtN80j/SDLylZmg==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/server@2.15.3': - resolution: {integrity: sha512-Ju4ty4/7JtmvivcP7gKReOLf8KrFwN7Yx/5VhXYh4TRULy4kSo2fsDVUaluPp0neZa6PbVhizJuzlOim73IEbQ==} + '@hocuspocus/transformer@3.4.3': + resolution: {integrity: sha512-jQZiqFGCvGQJLgE0nHZ4TdpEJlI7WkM8CKA1wLcs0beVs0kNXg32lykGckjveJwwJuJ/hieMqIEqj9POxTWPEw==} peerDependencies: - y-protocols: ^1.0.6 - yjs: ^13.6.8 - - '@hocuspocus/transformer@2.15.3': - resolution: {integrity: sha512-01UU3iZA9MF+MmB2SweKyC70nBM/FkBt3veWiAMoXPiegUG47wY8QO2MksBD/ucnz7C5M/0oAsTjqrx+j0ynIw==} - peerDependencies: - '@tiptap/core': ^2.6.4 - '@tiptap/pm': ^2.6.4 + '@tiptap/core': ^3.0.1 + '@tiptap/pm': ^3.0.1 y-prosemirror: 1.3.7 yjs: ^13.6.8 @@ -3393,9 +3384,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -4241,271 +4229,261 @@ packages: peerDependencies: react: ^18 || ^19 - '@tiptap/core@2.27.1': - resolution: {integrity: sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==} + '@tiptap/core@3.17.1': + resolution: {integrity: sha512-f8hB9MzXqsuXoF9qXEDEH5Fb3VgwhEFMBMfk9EKN88l5adri6oM8mt2XOWVxVVssjpEW0177zXSLPKWzoS/vrw==} peerDependencies: - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-blockquote@2.27.1': - resolution: {integrity: sha512-QrUX3muElDrNjKM3nqCSAtm3H3pT33c6ON8kwRiQboOAjT/9D57Cs7XEVY7r6rMaJPeKztrRUrNVF9w/w/6B0A==} + '@tiptap/extension-blockquote@3.17.1': + resolution: {integrity: sha512-X4jU/fllJQ8QbjCHUafU4QIHBobyXP3yGBoOcXxUaKlWbLvUs0SQTREM3n6/86m2YyAxwTPG1cn3Xypf42DMAQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-bold@2.27.1': - resolution: {integrity: sha512-g4l4p892x/r7mhea8syp3fNYODxsDrimgouQ+q4DKXIgQmm5+uNhyuEPexP3I8TFNXqQ4DlMNFoM9yCqk97etQ==} + '@tiptap/extension-bold@3.17.1': + resolution: {integrity: sha512-PZmrljcVBziJkQDXT/QJv4ESxVVQ0iRH+ruTzPda56Kk4h2310cSXGjI33W7rlCikGPoBAAjY/inujm46YB4bw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-bubble-menu@2.27.1': - resolution: {integrity: sha512-ki1R27VsSvY2tT9Q2DIlcATwLOoEjf5DsN+5sExarQ8S/ZxT/tvIjRxB8Dx7lb2a818W5f/NER26YchGtmHfpg==} + '@tiptap/extension-bubble-menu@3.17.1': + resolution: {integrity: sha512-z3E8biLiWlzZJwNHnB6j/ZyBdFrJmpl1lqKHc72JqahUHZvidZHdCOYssvR3fc6IaI7MXV13XY1DXUdFbatnaw==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-bullet-list@2.27.1': - resolution: {integrity: sha512-5FmnfXkJ76wN4EbJNzBhAlmQxho8yEMIJLchTGmXdsD/n/tsyVVtewnQYaIOj/Z7naaGySTGDmjVtLgTuQ+Sxw==} + '@tiptap/extension-bullet-list@3.17.1': + resolution: {integrity: sha512-2zw17XHruOJQK7ntLVq0PmOLajFhvQ+U4/qTfJnV3VOsHkm+2GPAksFe7I7+X0XmSmDru0pcT339Yywx/6Aykw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-character-count@2.27.2': - resolution: {integrity: sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==} + '@tiptap/extension-code-block@3.17.1': + resolution: {integrity: sha512-h4i+Y/cN7nMi0Tmlp6V1w4dI7NTqrUFSr1W/vMqnq4vn+c6jvm35KubKU5ry/1qQp8KfndDA02BtVQiMx6DmpA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-code-block-lowlight@2.27.1': - resolution: {integrity: sha512-Ijg9724uX/l4LXLELEeztZIgg+bDE/jJCkgS1+mavkRA/qtidpQkHo7L/Ry22fmj/ktCtZLjPXE5JAPAoRU6zA==} + '@tiptap/extension-code@3.17.1': + resolution: {integrity: sha512-4W0x1ZZqSnIVzQV0/b5VR0bktef2HykH5I/Czzir9yqoZ5zV2cLrMVuLvdFNgRIckU60tQLmHrfKWLF50OY0ew==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/extension-code-block': ^2.7.0 - '@tiptap/pm': ^2.7.0 - highlight.js: ^11 - lowlight: ^2 || ^3 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-code-block@2.27.1': - resolution: {integrity: sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==} + '@tiptap/extension-collaboration-caret@3.17.1': + resolution: {integrity: sha512-tYzujG4ABacSbjd8QOqMt1IP3QdCmAEBHP2faF4SeFauaP6Nto88JvTiZVCHad0BBwiNrj4UPGZSujcNQiLjTA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@tiptap/y-tiptap': ^3.0.2 - '@tiptap/extension-code@2.27.1': - resolution: {integrity: sha512-i65wUGJevzBTIIUBHBc1ggVa27bgemvGl/tY1/89fEuS/0Xmre+OQjw8rCtSLevoHSiYYLgLRlvjtUSUhE4kgg==} + '@tiptap/extension-collaboration@3.17.1': + resolution: {integrity: sha512-4ehZ5LL7M3nFfcogCG7bWRHIR/8366i1vz5i0PaaoArJga2N5sXnWcuBGXG7ykC8owbgrfL3agFxjHlhTl4sNw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@tiptap/y-tiptap': ^3.0.2 + yjs: ^13 - '@tiptap/extension-collaboration-cursor@2.27.1': - resolution: {integrity: sha512-k4vLA1QeGM4FfO9BMKw8O0Nxv2zDrsUpnP7wKAJp/zmr2lHbQX86cO+SGEy+kcRtPeIp6Y4Phytp6F+1HMjbLA==} + '@tiptap/extension-color@3.17.1': + resolution: {integrity: sha512-QVlzpzGB+QcZgHgvFMRPckZutpkOLzNmZzhupNA7G2CMeeoCwZOJeZkyd3zvtAnRZkf7FrQBO123On30pJt7TA==} peerDependencies: - '@tiptap/core': ^2.7.0 - y-prosemirror: 1.3.7 + '@tiptap/extension-text-style': ^3.17.1 - '@tiptap/extension-collaboration@2.27.1': - resolution: {integrity: sha512-fR35dIYDHM9870zl2sHaA2ytSVcjASv8Nfnb1Mgslt/F3Lqsu9TOv/oJWi9nYBvjjrfK0RNaoGFVH7p2z7FR3w==} + '@tiptap/extension-document@3.17.1': + resolution: {integrity: sha512-F7Q5HoAU383HWFa6AXZQ5N6t6lTJzVjYM8z93XrtH/2GzDFwy1UmDSrsXqvgznedBLAOgCNVTNh9PjXpLoOUbg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 - y-prosemirror: 1.3.7 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-color@2.27.1': - resolution: {integrity: sha512-raYRsdG2tZvVvY1LV/VTZnDG44Y0xRBwo5CZEat0OUqdx34dfvCtYm8HIOTyWBwr7OOW+yR4O1Vc2zFkmfthZw==} + '@tiptap/extension-dropcursor@3.17.1': + resolution: {integrity: sha512-EKJYPb7OSk3p9mX1SmHt4ccw89w1P1d55hC8aPtZJ6jxAUd5MSuVwvEEVz7LGldUZD9HZz9WFQ0Sv9U73Bpkmw==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/extension-text-style': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-document@2.27.1': - resolution: {integrity: sha512-NtJzJY7Q/6XWjpOm5OXKrnEaofrcc1XOTYlo/SaTwl8k2bZo918Vl0IDBWhPVDsUN7kx767uHwbtuQZ+9I82hA==} + '@tiptap/extension-floating-menu@3.17.1': + resolution: {integrity: sha512-zYkoYsxp+cZ8tBDODm4E8hnSaMTdDWKJuCQWY2Ep14oMPkAkSJr8sCLL1tOnNSAnhGwLJQtRLkZ41nvUEP6xKA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-dropcursor@2.27.1': - resolution: {integrity: sha512-3MBQRGHHZ0by3OT0CWbLKS7J3PH9PpobrXjmIR7kr0nde7+bHqxXiVNuuIf501oKU9rnEUSedipSHkLYGkmfsA==} + '@tiptap/extension-gapcursor@3.17.1': + resolution: {integrity: sha512-xItmJZTi+Z6UbLBhpBBL9RZDNbDXf+ntWVgblAmxtpyEyNh5k5tkM6IP9SJRhk92uVfnFpH9qkGo66a537I8QA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-floating-menu@2.27.1': - resolution: {integrity: sha512-nUk/8DbiXO69l6FDwkWso94BTf52IBoWALo+YGWT6o+FO6cI9LbUGghEX2CdmQYXCvSvwvISF2jXeLQWNZvPZQ==} + '@tiptap/extension-hard-break@3.17.1': + resolution: {integrity: sha512-28FZPUho1Q2AB3ka5SVEVib5f9dMKbE1kewLZeRIOQ5FuFNholGIPL5X1tKcwGW7G3A7Y0fGxeNmIZJ3hrqhzA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-gapcursor@2.27.1': - resolution: {integrity: sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==} + '@tiptap/extension-heading@3.17.1': + resolution: {integrity: sha512-rT+Su/YnHdlikg8f78t6RXlc1sVSfp7B0fdJdtFgS2e6BBYJQoDMp5L9nt54RR9Yy953aDW2sko7NArUCb8log==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-hard-break@2.27.1': - resolution: {integrity: sha512-W4hHa4Io6QCTwpyTlN6UAvqMIQ7t56kIUByZhyY9EWrg/+JpbfpxE1kXFLPB4ZGgwBknFOw+e4bJ1j3oAbTJFw==} + '@tiptap/extension-highlight@3.17.1': + resolution: {integrity: sha512-I4EdBhPVzJd4ECMI9kP0NE4aG4Numd46jy/AqeZyf3dqVgCxRyAbSyU7oy4aXUnsojYODrKKG6+djm07KgOGoQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-heading@2.27.1': - resolution: {integrity: sha512-6xoC7igZlW1EmnQ5WVH9IL7P1nCQb3bBUaIDLvk7LbweEogcTUECI4Xg1vxMOVmj9tlDe1I4BsgfcKpB5KEsZw==} + '@tiptap/extension-history@3.17.1': + resolution: {integrity: sha512-YHW4HP9ovZ/zqc1u3+cDdAY/LITaMQNRnX5foLsDFLV5FU+zqonYo2CqDkVwaQs9UfCp9PM0ehZzxMI8hc58oA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-highlight@2.27.1': - resolution: {integrity: sha512-ntuYX09tvHQE/R/8WbTOxbFuQhRr2jhTkKz/gLwDD2o8IhccSy3f0nm+mVmVamKQnbsBBbLohojd5IGOnX9f1A==} + '@tiptap/extension-horizontal-rule@3.17.1': + resolution: {integrity: sha512-CHG6LBtxV+3qj5EcCRVlpvSW5udKD6KbnXIGhP+Tvy+OabLGzO4HNxz3+duDE0pMR4eKX1libsnqffj0vq7mnQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-history@2.27.1': - resolution: {integrity: sha512-K8PHC9gegSAt0wzSlsd4aUpoEyIJYOmVVeyniHr1P1mIblW1KYEDbRGbDlrLALTyUEfMcBhdIm8zrB9X2Nihvg==} + '@tiptap/extension-image@3.17.1': + resolution: {integrity: sha512-VbSSZ//5qijm8F0lQQ6K+DGnZgjLKYQY2c+O56QNEoN8BaCFrJlsVgF1ttrSRUmoG4XBNIMlAS07kZXvMZQr0g==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-horizontal-rule@2.27.1': - resolution: {integrity: sha512-WxXWGEEsqDmGIF2o9av+3r9Qje4CKrqrpeQY6aRO5bxvWX9AabQCfasepayBok6uwtvNzh3Xpsn9zbbSk09dNA==} + '@tiptap/extension-italic@3.17.1': + resolution: {integrity: sha512-unfRLmvf680Y0UkBToUcrDkSEKO/wAjd3nQ7CNPMfAc8m+ZMReXkcgLpeVvnDEiHNsJ0PlYSW7a45tnQD9HQdg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-image@2.27.1': - resolution: {integrity: sha512-wu3vMKDYWJwKS6Hrw5PPCKBO2RxyHNeFLiA/uDErEV7axzNpievK/U9DyaDXmtK3K/h1XzJAJz19X+2d/pY68w==} + '@tiptap/extension-link@3.17.1': + resolution: {integrity: sha512-5kdN7vms5hMXtjiophUkgvzy8dNGvGSmol1Sawh30TEPrgXc93Ayj7YyGZlbimInKZcD8q+Od/FFc+wkrof3nA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-italic@2.27.1': - resolution: {integrity: sha512-rcm0GyniWW0UhcNI9+1eIK64GqWQLyIIrWGINslvqSUoBc+WkfocLvv4CMpRkzKlfsAxwVIBuH2eLxHKDtAREA==} + '@tiptap/extension-list-item@3.17.1': + resolution: {integrity: sha512-Qjj4oIa44cTX0E6aw/4+wleqX21t5jMDxeSqP5uQ8Q3IdD1GoR5+yo+41XAHELaeZOXLHLkAIbzIxik3pOqO8w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-link@2.27.1': - resolution: {integrity: sha512-cCwWPZsnVh9MXnGOqSIRXPPuUixRDK8eMN2TvqwbxUBb1TU7b/HtNvfMU4tAOqAuMRJ0aJkFuf3eB0Gi8LVb1g==} + '@tiptap/extension-list-keymap@3.17.1': + resolution: {integrity: sha512-zRidxbkJNe/j3nZpOGLnPeVdyciUM8MM+NHhxcjVKoNDA+/zEBfjXJ1dKC4UBsnSr4AS/3SCWBYHGXOoSqdUaA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-list-item@2.27.1': - resolution: {integrity: sha512-dtsxvtzxfwOJP6dKGf0vb2MJAoDF2NxoiWzpq0XTvo7NGGYUHfuHjX07Zp0dYqb4seaDXjwsi5BIQUOp3+WMFQ==} + '@tiptap/extension-list@3.17.1': + resolution: {integrity: sha512-LHKIxmXe5Me+vJZKhiwMBGHlApaBIAduNMRUpm5mkY7ER/m96zKR0VqrJd4LjVVH2iDvck5h1Ka4396MHWlKNg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-list-keymap@2.27.1': - resolution: {integrity: sha512-k7+Ulz9B1NjqwU6NEFYkJh4rGGT/iRVaCBa8OL9YYrVS3H44LgEqUCEbRu6TeEq4XXrLwueQpkkyl4Evi15lAQ==} + '@tiptap/extension-ordered-list@3.17.1': + resolution: {integrity: sha512-pahAXbVajqX0Y51Zge9jKZlCtPV1oiq5Fbzs7gHF80KICIKf44i/AsUvfdJyT2N5/8kZrAMQHEiU/UgTMrhM3w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extension-list': ^3.17.1 - '@tiptap/extension-ordered-list@2.27.1': - resolution: {integrity: sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==} + '@tiptap/extension-paragraph@3.17.1': + resolution: {integrity: sha512-Vl+xAlINaPtX8XTPvPmeveYMEIMLs8gA7ItcKpyyo4cCzAfVCY3DKuWzOkQGUf7DKrhyJQZhpgLNMaq+h5sTSw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-paragraph@2.27.1': - resolution: {integrity: sha512-R3QdrHcUdFAsdsn2UAIvhY0yWyHjqGyP/Rv8RRdN0OyFiTKtwTPqreKMHKJOflgX4sMJl/OpHTpNG1Kaf7Lo2A==} + '@tiptap/extension-placeholder@3.17.1': + resolution: {integrity: sha512-cE8Rij5/1t4KnWE7GaDewhBek9DKNB+97yrxyggMegILg6v195hOmOkRZkyfnFMYZoBDlrfSAtX9wBvbZBqIsg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/extensions': ^3.17.1 - '@tiptap/extension-placeholder@2.27.1': - resolution: {integrity: sha512-UbXaibHHFE+lOTlw/vs3jPzBoj1sAfbXuTAhXChjgYIcTTY5Cr6yxwcymLcimbQ79gf04Xkua2FCN3YsJxIFmw==} + '@tiptap/extension-strike@3.17.1': + resolution: {integrity: sha512-c6fS6YIhxoU55etlJgM0Xqker+jn7I1KC7GVu6ljmda8I00K3/lOLZgvFUNPmgp8EJWtyTctj+3D3D+PaZaFAA==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-strike@2.27.1': - resolution: {integrity: sha512-S9I//K8KPgfFTC5I5lorClzXk0g4lrAv9y5qHzHO5EOWt7AFl0YTg2oN8NKSIBK4bHRnPIrjJJKv+dDFnUp5jQ==} + '@tiptap/extension-subscript@3.17.1': + resolution: {integrity: sha512-+y/sl1d+TcecX2n1r6ZTjBmY3D6cfqAW86iKsvudCFSpp9SQk85RaumPzELOXWOjz9g0mtfUnXifrLYF3dS+vA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-subscript@2.27.1': - resolution: {integrity: sha512-n2jTaYriewwz3ES1o6Wt/OwREvPwi97n+yEsJ7i31wiuxGTdCP31eAuppC6DvixEvDt3/rZMZcNp8Ah9crlbnw==} + '@tiptap/extension-superscript@3.17.1': + resolution: {integrity: sha512-FKt+lI1ocFRW0EFla9EuO71aLQINpkC/wt9zxWnJJnfPIWfxYlsTSFJLjLkVungTmwfeCnoCVcXnZ0dSKDnoGg==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-superscript@2.27.1': - resolution: {integrity: sha512-zTYOD7k3txm21rjeYHsf/VIpBe9IvVfNHSNayyY/JOgyQ/fW40cgX0gADNoT2ayAtRes4TvpcUYdgF9vC5bkJw==} + '@tiptap/extension-table@3.17.1': + resolution: {integrity: sha512-FuAMdmM330tHJUYT5IV2ooFRqtXf+0D8llcE9nIQQCXKL4J0pfGSOIm40LVpunYgx2pV8SSCL51qTBuEmR84tQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-table-cell@2.27.1': - resolution: {integrity: sha512-VowNmz1kub2qfntWkU8jGA6DoCl9xjJBWSypuQIeiN/IRId3BMrJodT26pTNJ3ChDMtYaanWaUvYqckRxgTC2A==} + '@tiptap/extension-text-align@3.17.1': + resolution: {integrity: sha512-CyJbZf823dqPZ/1zwRsza5pk/NQwFZwILdFYLVkV88I4+Ua9YVztI9kmwTB6dJyuKT4kTc7nhQHdaa957alGZQ==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table-header@2.27.1': - resolution: {integrity: sha512-lSbGB6kBp/sTVzAWl4v7v7ztL5XU3aTdlS7FhfGjpdsxd4zPKYG8kx+Uxgq25W9/BlCbnqHnO0poAMfOlspDQw==} + '@tiptap/extension-text-style@3.17.1': + resolution: {integrity: sha512-TCMsEU92r/TfZkN8AKo/WIcJ1uNq/5NiZxloq5drF1HXxDDjwliurgwBw3OTGUlKQmer0N9hV0AAePY/G+5Akw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table-row@2.27.1': - resolution: {integrity: sha512-3xtlmZ6NWDi5a42gK0qQQTeBUpJ2j1o7qyXTFkhQaJAeIFEqsemgSRhgXZxbwSmQQZsPJ/86KWBNVkT0FaRFDw==} + '@tiptap/extension-text@3.17.1': + resolution: {integrity: sha512-rGml96vokQbvPB+w6L3+WKyYJWwqELaLdFUr1WMgg+py5uNYGJYAExYNAbDb5biWJBrX9GgMlCaNeiJj849L1w==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-table@2.27.1': - resolution: {integrity: sha512-iOoOo0vYFzAogAZlw36DgmFfNM5vOkLqnApm81soO/YWpqtKAvBn+TMY4ss4OMDsOefUzBa6xqOJ0gJR5ZygjA==} + '@tiptap/extension-typography@3.17.1': + resolution: {integrity: sha512-bEocTrK/gryk3VtthC9Ca03p2kutVIIFnDkVW6iOG8PgQWEspuQRgqE8yPnHxY8pBBDWxiaBzcGTSrp+3U9d5A==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-task-item@2.27.1': - resolution: {integrity: sha512-vaEtdos+9jApD6yRfD6F/xShikiZFHi7I0nswAmGKT/kE1wmHCUxme8OFMe7642e2OK0lqgHsUaOLxP/0nZJ5A==} + '@tiptap/extension-underline@3.17.1': + resolution: {integrity: sha512-6RdBzmkg6DYs0EqPyoqLGkISXzCnPqM/q3A6nh3EmFmORcIDfuNmcidvA6EImebK8KQGmtZKsRhQSnK4CNQ39g==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-task-list@2.27.1': - resolution: {integrity: sha512-KRlYOZ6kdURvAspUrLVsC7mLkVW2DYhpj+7QxH7gVDZuAuoPUEmpJVcBVPq7GhPF9PccaRLru+n1Ege5VqvZ+Q==} + '@tiptap/extension-unique-id@3.17.1': + resolution: {integrity: sha512-R+lXBIaEmJ23rJMMLs6dPIVMhpv+TU8vEFVtpbMoOl/yfoc9Pvr6Q0EgLnRDX6l4yAekenem4KmGeG9CmuoskA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-text-align@2.27.1': - resolution: {integrity: sha512-D7dLPk7y5mDn9ZNANQ4K2gCq4vy+Emm5AdeWOGzNeqJsYrBotiQYXd9rb1QYjdup2kzAoKduMTUXV92ujo5cEg==} + '@tiptap/extension-youtube@3.17.1': + resolution: {integrity: sha512-AarpN4vI/S6jPMuLuFGEFLgdoasGiUW+rGLj+jH/0Of6l27nKRN00MTm/fD/62qjR6At3Rd7Xsue/GuXdmDUWw==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 - '@tiptap/extension-text-style@2.27.1': - resolution: {integrity: sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==} + '@tiptap/extensions@3.17.1': + resolution: {integrity: sha512-aQ4WA5bdRpv9yPQ6rRdiqwlMZ1eJw1HyEaNPQhOr2HVhQ0EqSDIOEXF4ymCveGAHxXbxNvtQ+4t1ymQEikGfXA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 - '@tiptap/extension-text@2.27.1': - resolution: {integrity: sha512-a4GCT+GZ9tUwl82F4CEum9/+WsuW0/De9Be/NqrMmi7eNfAwbUTbLCTFU0gEvv25WMHCoUzaeNk/qGmzeVPJ1Q==} + '@tiptap/html@3.17.1': + resolution: {integrity: sha512-fLb2fo8+3oQ+5FTx5IGZvLI5+VLgN9BM6pHaO1+IrwqQ5w2RBFIGp8M946asBPkxJ74EtzHqFKJpVFtaY2CcpA==} peerDependencies: - '@tiptap/core': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + happy-dom: ^20.0.2 - '@tiptap/extension-typography@2.27.1': - resolution: {integrity: sha512-jAZU5IuWH9CtZlolQ1gRhV+bT75s19SXjadQwkk18gMMiapcaIVVTxUDWY6ycv9ge4cjRoaP3lqBviW3cGqhOA==} + '@tiptap/pm@3.17.1': + resolution: {integrity: sha512-UyVLkN8axV/zop6Se2DCBJRu5DM21X0XEQvwEC5P/vk8eC9OcQZ3FLtxeYy2ZjpAZUzBGLw0/BGsmEip/n7olw==} + + '@tiptap/react@3.17.1': + resolution: {integrity: sha512-Hn/pIP3HG9xYnhI3iGrfVhgQhfIdOaEBSxOFzJ37patqSOlIoP5aZH/b2HZ4vgo5DdRlV56q7WtRC+vLIw4Neg==} peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/extension-underline@2.27.1': - resolution: {integrity: sha512-fPTmfJFAQWg1O/os1pYSPVdtvly6eW/w5sDofG7pre+bdQUN+8s1cZYelSuj/ltNVioRaB2Ws7tvNgnHL0aAJQ==} - peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/extension-youtube@2.27.1': - resolution: {integrity: sha512-HjBBgE0Zbch/S2UP0YYQXervfoBd4Trw0dYmlZbX9cXJcZv+QFx0vsPGmjAGlqzXf9Y8ZioWm8fso4u6AsUfTw==} - peerDependencies: - '@tiptap/core': ^2.7.0 - - '@tiptap/html@2.27.1': - resolution: {integrity: sha512-5iPo36g4nbBVoEVBQb6my4KNpNzu38gtCFXIIlAJdAZQvPs+XC8TkrnGK/G4UGpwBXCuQjSQm0iyn4znmQPDsw==} - peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 - - '@tiptap/pm@2.27.1': - resolution: {integrity: sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==} - - '@tiptap/react@2.27.1': - resolution: {integrity: sha512-leJximSjYJuhLJQv9azOP9R7w6zuxVgKOHYT4w83Gte7GhWMpNL6xRWzld280vyq/YW/cSYjPb/8ESEOgKNBdQ==} - peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@2.27.1': - resolution: {integrity: sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==} + '@tiptap/starter-kit@3.17.1': + resolution: {integrity: sha512-3vBGqag9mwuQoWTrfQlULtHeoFs7k/2Q8CREf3Y79hv2fqAXTvTOKlWYPSgZhiGVMp6Dti7BDiE9Y1QpvAat2g==} - '@tiptap/suggestion@2.27.1': - resolution: {integrity: sha512-yTy75ZMYgVWM18cl7YxLqMJ7TorQTGysSd1aKmBA9qd8uzYlvLMmHKE9qBDxM9HXODBz1DA/BLLm9esv2enmFw==} + '@tiptap/suggestion@3.17.1': + resolution: {integrity: sha512-a188uVYjlLsUiwK3Ki7KsaWVWC0u28KsqGEAqCk9ECYmtVY99Hrb+rcAwGpMjA7tn8WAwThOxiLISoMdpuqXwg==} peerDependencies: - '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/core': ^3.17.1 + '@tiptap/pm': ^3.17.1 + + '@tiptap/y-tiptap@3.0.1': + resolution: {integrity: sha512-F3hj5X77ckmyIywbCQpKgyX3xKra2/acJPWaV5R9wqp0cUPBmm62FYbkQ6HaqxH1VhCkUhhAZcDSQjbjj7tnWw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} @@ -4802,9 +4780,6 @@ packages: '@types/passport@1.0.17': resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} - '@types/pg@8.11.11': - resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} - '@types/prop-types@15.7.11': resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -4844,6 +4819,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -5273,6 +5251,9 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} @@ -5379,9 +5360,6 @@ packages: bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -5625,6 +5603,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + columnify@1.6.0: resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} engines: {node: '>=8.0.0'} @@ -5985,6 +5966,9 @@ packages: date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} @@ -6087,10 +6071,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - denque@1.5.1: - resolution: {integrity: sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==} - engines: {node: '>=0.10'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -6274,10 +6254,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@5.0.0: - resolution: {integrity: sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -6497,6 +6473,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -6506,6 +6485,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.3.4: + resolution: {integrity: sha512-d+yU9iNQbbC098NOuMlAIth/g+owbpX/uuOkH/DQcC2fMMyjOlX292Op29DrUKq388m4UUyOdWakUH/msGypOg==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -6846,6 +6829,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hexoid@1.0.0: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} @@ -6927,6 +6913,11 @@ packages: image-blob-reduce@3.0.1: resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + image-dimensions@2.5.0: + resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==} + engines: {node: '>=18'} + hasBin: true + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -6979,10 +6970,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@4.28.5: - resolution: {integrity: sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==} - engines: {node: '>=6'} - ioredis@5.4.1: resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} engines: {node: '>=12.22.0'} @@ -7380,6 +7367,10 @@ packages: react: optional: true + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-beautify@1.15.1: resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} engines: {node: '>=14'} @@ -7549,6 +7540,16 @@ packages: resolution: {integrity: sha512-904MSUdzkdxl+k3C67ogvP6ogPOEr0D6ZZDtxAmDeIHEJxZAA+eC+TLAcJt3HQABTPetwsW3pj6y1MPmaveQUg==} hasBin: true + kysely-postgres-js@3.0.0: + resolution: {integrity: sha512-o2t/xNSYJQDW6rVGGFPXKmZ0BEz2dGn66c2B+cO/k9ZNcU2qPWPycQPQ+B+P2MBXbKYq0xV9BZmFIvkUrmFWAQ==} + engines: {bun: '>=1.2', node: '>=20'} + peerDependencies: + kysely: '>= 0.24.0 < 1' + postgres: ^3.4.0 + peerDependenciesMeta: + postgres: + optional: true + kysely@0.28.2: resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} engines: {node: '>=18.0.0'} @@ -7600,18 +7601,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lib0@0.2.108: - resolution: {integrity: sha512-+3eK/B0SqYoZiQu9fNk4VEc6EX8cb0Li96tPGKgugzoGj/OdRdREtuTLvUW+mtinoB2mFiJjSqOJBIaMkAGhxQ==} - engines: {node: '>=16'} - hasBin: true - - lib0@0.2.114: - resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} - engines: {node: '>=16'} - hasBin: true - - lib0@0.2.88: - resolution: {integrity: sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} engines: {node: '>=16'} hasBin: true @@ -7669,9 +7660,6 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - lodash.flatten@4.4.0: - resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} - lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -7990,8 +7978,8 @@ packages: resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} hasBin: true - msgpackr@1.11.2: - resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} multimath@2.0.0: resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} @@ -8047,6 +8035,15 @@ packages: kysely: 0.x reflect-metadata: ^0.1.13 || ^0.2.2 + nestjs-pino@4.5.0: + resolution: {integrity: sha512-e54ChJMACSGF8gPYaHsuD07RW7l/OVoV6aI8Hqhpp0ZQ4WA8QY3eewL42JX7Z1U6rV7byNU7bGBV9l6d9V6PDQ==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + next@14.2.10: resolution: {integrity: sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww==} engines: {node: '>=18.17.0'} @@ -8186,9 +8183,6 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} - obuf@1.1.2: - resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - oidc-token-hash@5.0.3: resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} engines: {node: ^10.13.0 || >=12.0.0} @@ -8281,10 +8275,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@2.1.0: - resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} - engines: {node: '>=6'} - p-queue@6.6.2: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} @@ -8417,10 +8407,6 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-numeric@1.0.2: - resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} - engines: {node: '>=4'} - pg-pool@3.10.1: resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} peerDependencies: @@ -8429,9 +8415,6 @@ packages: pg-protocol@1.10.3: resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} - pg-protocol@1.7.0: - resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} - pg-tsquery@8.4.2: resolution: {integrity: sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA==} engines: {node: '>=10'} @@ -8440,10 +8423,6 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} - engines: {node: '>=10'} - pg@8.16.3: resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} engines: {node: '>= 16.0.0'} @@ -8491,6 +8470,16 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} @@ -8591,37 +8580,22 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} - postgres-array@3.0.2: - resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} - engines: {node: '>=12'} - postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} - postgres-bytea@3.0.0: - resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} - engines: {node: '>= 6'} - postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} - postgres-date@2.1.0: - resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} - engines: {node: '>=12'} - postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - postgres-interval@3.0.0: - resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - postgres-range@1.1.4: - resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - posthog-js@1.255.1: resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==} peerDependencies: @@ -8717,8 +8691,8 @@ packages: prosemirror-schema-basic@1.2.3: resolution: {integrity: sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==} - prosemirror-schema-list@1.4.1: - resolution: {integrity: sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==} + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} prosemirror-state@1.4.3: resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} @@ -8748,6 +8722,9 @@ packages: prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -8985,9 +8962,6 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - redis-commands@1.7.0: - resolution: {integrity: sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -8996,10 +8970,6 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - redlock@4.2.0: - resolution: {integrity: sha512-j+oQlG+dOwcetUt2WJWttu4CZVeRzUrcVcISFmEmfyuwCVSJ93rDT7YSgg7H7rnxwoRyk/jU46kycVka5tW7jA==} - engines: {node: '>=8.0.0'} - redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -9435,6 +9405,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + stripe@17.5.0: resolution: {integrity: sha512-kcyeAkDFjGsVl17FqnG7q/+xIjt0ZjOo9Dm+q8deAvs2Xe4iAHrhxyoP4etUVFc+/LZJANjIPVR+ZOnt9hr/Ug==} engines: {node: '>=12.*'} @@ -9554,9 +9528,6 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tippy.js@6.3.7: - resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - tiptap-extension-global-drag-handle@0.1.18: resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} @@ -9706,6 +9677,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tseep@1.3.1: + resolution: {integrity: sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==} + tslib@2.8.0: resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} @@ -9906,6 +9880,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.4: resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==} @@ -10291,8 +10270,8 @@ packages: resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} engines: {node: '>=12'} - yjs@13.6.27: - resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} yn@3.1.1: @@ -10311,10 +10290,6 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} - zeed-dom@0.15.1: - resolution: {integrity: sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==} - engines: {node: '>=14.13.1'} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -12228,13 +12203,13 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} - '@casl/ability@6.7.5': + '@casl/ability@6.8.0': dependencies: '@ucast/mongo2js': 1.3.4 - '@casl/react@4.0.0(@casl/ability@6.7.5)(react@18.3.1)': + '@casl/react@4.0.0(@casl/ability@6.8.0)(react@18.3.1)': dependencies: - '@casl/ability': 6.7.5 + '@casl/ability': 6.8.0 react: 18.3.1 '@cfworker/json-schema@4.1.1': {} @@ -12691,58 +12666,43 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@hocuspocus/common@2.15.3': + '@hocuspocus/common@3.4.3': dependencies: - lib0: 0.2.114 + lib0: 0.2.117 - '@hocuspocus/extension-redis@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/provider@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/server': 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) - ioredis: 4.28.5 - kleur: 4.1.5 - lodash.debounce: 4.0.8 - redlock: 4.2.0 - uuid: 11.1.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': - dependencies: - '@hocuspocus/common': 2.15.3 + '@hocuspocus/common': 3.4.3 '@lifeomic/attempt': 3.0.3 - lib0: 0.2.114 + lib0: 0.2.117 ws: 8.19.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/server@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/server@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@hocuspocus/common': 2.15.3 + '@hocuspocus/common': 3.4.3 async-lock: 1.4.1 + async-mutex: 0.5.0 kleur: 4.1.5 - lib0: 0.2.114 - uuid: 11.1.0 + lib0: 0.2.117 ws: 8.19.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 transitivePeerDependencies: - bufferutil - utf-8-validate - '@hocuspocus/transformer@2.15.3(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/transformer@3.4.3(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - '@tiptap/starter-kit': 2.27.1 - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) - yjs: 13.6.27 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/starter-kit': 3.17.1 + y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + yjs: 13.6.29 '@humanfs/core@0.19.1': {} @@ -13765,8 +13725,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@popperjs/core@2.11.8': {} - '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -14640,212 +14598,195 @@ snapshots: '@tanstack/query-core': 5.90.17 react: 18.3.1 - '@tiptap/core@2.27.1(@tiptap/pm@2.27.1)': + '@tiptap/core@3.17.1(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/pm': 2.27.1 + '@tiptap/pm': 3.17.1 - '@tiptap/extension-blockquote@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-blockquote@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-bold@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-bold@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-bubble-menu@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-bubble-menu@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - tippy.js: 6.3.7 + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + optional: true - '@tiptap/extension-bullet-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-bullet-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-character-count@2.27.2(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-code-block@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-code-block-lowlight@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(highlight.js@11.11.1)(lowlight@3.3.0)': + '@tiptap/extension-code@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-code-block': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - highlight.js: 11.11.1 - lowlight: 3.3.0 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-code-block@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-collaboration-caret@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) - '@tiptap/extension-code@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-collaboration@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) + yjs: 13.6.29 - '@tiptap/extension-collaboration-cursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': + '@tiptap/extension-color@3.17.1(@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@tiptap/extension-text-style': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) - '@tiptap/extension-collaboration@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': + '@tiptap/extension-document@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-color@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)))': + '@tiptap/extension-dropcursor@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-document@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-floating-menu@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@floating-ui/dom': 1.7.3 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + optional: true - '@tiptap/extension-dropcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-gapcursor@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-floating-menu@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-hard-break@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - tippy.js: 6.3.7 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-gapcursor@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-heading@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-hard-break@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-highlight@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-heading@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-history@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-highlight@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-horizontal-rule@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-history@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-image@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-horizontal-rule@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-italic@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-image@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-link@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/extension-italic@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/extension-link@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list-item@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-list-keymap@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list-keymap@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-ordered-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-paragraph@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-ordered-list@3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-placeholder@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-paragraph@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-strike@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-placeholder@3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) - '@tiptap/extension-subscript@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-strike@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-superscript@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-subscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-cell@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-superscript@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-header@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-table@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-table-row@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-text-align@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-table@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-text-style@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-task-item@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/extension-text@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-task-list@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-typography@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-text-align@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-underline@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-text-style@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-unique-id@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + uuid: 10.0.0 - '@tiptap/extension-text@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extension-youtube@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) - '@tiptap/extension-typography@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/extension-underline@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': + '@tiptap/html@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(happy-dom@20.1.0)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + happy-dom: 20.1.0 - '@tiptap/extension-youtube@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - - '@tiptap/html@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': - dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 - zeed-dom: 0.15.1 - - '@tiptap/pm@2.27.1': + '@tiptap/pm@3.17.1': dependencies: prosemirror-changeset: 2.3.1 prosemirror-collab: 1.3.1 @@ -14859,53 +14800,70 @@ snapshots: prosemirror-menu: 1.2.4 prosemirror-model: 1.25.1 prosemirror-schema-basic: 1.2.3 - prosemirror-schema-list: 1.4.1 + prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.3 prosemirror-tables: 1.7.1 prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0) prosemirror-transform: 1.10.4 prosemirror-view: 1.40.0 - '@tiptap/react@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tiptap/react@3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-bubble-menu': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-floating-menu': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 '@types/use-sync-external-store': 0.0.6 - fast-deep-equal: 3.1.3 + fast-equals: 5.3.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - use-sync-external-store: 1.2.2(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-floating-menu': 3.17.1(@floating-ui/dom@1.7.3)(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + transitivePeerDependencies: + - '@floating-ui/dom' - '@tiptap/starter-kit@2.27.1': + '@tiptap/starter-kit@3.17.1': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/extension-blockquote': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-bold': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-bullet-list': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-code': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-code-block': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-document': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-dropcursor': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-gapcursor': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-hard-break': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-heading': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-history': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-horizontal-rule': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1) - '@tiptap/extension-italic': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-list-item': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-ordered-list': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-paragraph': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-strike': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-text': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/extension-text-style': 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1)) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/extension-blockquote': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-bold': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-bullet-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-code': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-code-block': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-document': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-dropcursor': 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-gapcursor': 3.17.1(@tiptap/extensions@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-hard-break': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-heading': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-horizontal-rule': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-italic': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-link': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/extension-list-item': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-list-keymap': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-ordered-list': 3.17.1(@tiptap/extension-list@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)) + '@tiptap/extension-paragraph': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-strike': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-text': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extension-underline': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1)) + '@tiptap/extensions': 3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 - '@tiptap/suggestion@2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)': + '@tiptap/suggestion@3.17.1(@tiptap/core@3.17.1(@tiptap/pm@3.17.1))(@tiptap/pm@3.17.1)': dependencies: - '@tiptap/core': 2.27.1(@tiptap/pm@2.27.1) - '@tiptap/pm': 2.27.1 + '@tiptap/core': 3.17.1(@tiptap/pm@3.17.1) + '@tiptap/pm': 3.17.1 + + '@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.1 + prosemirror-state: 1.4.3 + prosemirror-view: 1.40.0 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 '@tokenizer/inflate@0.4.1': dependencies: @@ -15265,12 +15223,6 @@ snapshots: dependencies: '@types/express': 4.17.23 - '@types/pg@8.11.11': - dependencies: - '@types/node': 22.19.1 - pg-protocol: 1.7.0 - pg-types: 4.0.2 - '@types/prop-types@15.7.11': {} '@types/qrcode@1.5.5': @@ -15319,6 +15271,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.2': {} @@ -15838,6 +15792,10 @@ snapshots: async-lock@1.4.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async@3.2.5: {} asynckit@0.4.0: {} @@ -16020,8 +15978,6 @@ snapshots: bluebird@3.4.7: {} - bluebird@3.7.2: {} - boolbase@1.0.0: {} bowser@2.11.0: {} @@ -16100,7 +16056,7 @@ snapshots: dependencies: cron-parser: 4.9.0 ioredis: 5.8.2 - msgpackr: 1.11.2 + msgpackr: 1.11.8 node-abort-controller: 3.1.1 semver: 7.7.2 tslib: 2.8.1 @@ -16295,6 +16251,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colorette@2.0.20: {} + columnify@1.6.0: dependencies: strip-ansi: 6.0.1 @@ -16702,6 +16660,8 @@ snapshots: date-fns@4.1.0: {} + dateformat@4.6.3: {} + dayjs@1.11.13: {} dayjs@1.11.19: {} @@ -16768,8 +16728,6 @@ snapshots: delayed-stream@1.0.0: {} - denque@1.5.1: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -16959,8 +16917,6 @@ snapshots: entities@4.5.0: {} - entities@5.0.0: {} - entities@6.0.1: {} env-paths@2.2.1: {} @@ -17340,12 +17296,16 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-copy@4.0.2: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@2.0.1: {} fast-deep-equal@3.1.3: {} + fast-equals@5.3.4: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -17724,6 +17684,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + hexoid@1.0.0: {} highlight.js@11.11.1: {} @@ -17822,6 +17784,8 @@ snapshots: dependencies: pica: 7.1.1 + image-dimensions@2.5.0: {} + image-size@0.5.5: optional: true @@ -17866,22 +17830,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@4.28.5: - dependencies: - cluster-key-slot: 1.1.2 - debug: 4.4.1 - denque: 1.5.1 - lodash.defaults: 4.2.0 - lodash.flatten: 4.4.0 - lodash.isarguments: 3.1.0 - p-map: 2.1.0 - redis-commands: 1.7.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ioredis@5.4.1: dependencies: '@ioredis/commands': 1.2.0 @@ -18492,6 +18440,8 @@ snapshots: '@types/react': 18.3.12 react: 18.3.1 + joycon@3.1.1: {} + js-beautify@1.15.1: dependencies: config-chain: 1.1.13 @@ -18663,6 +18613,12 @@ snapshots: '@commander-js/extra-typings': 11.1.0(commander@11.1.0) commander: 11.1.0 + kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): + dependencies: + kysely: 0.28.2 + optionalDependencies: + postgres: 3.4.8 + kysely@0.28.2: {} langium@3.3.1: @@ -18724,15 +18680,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lib0@0.2.108: - dependencies: - isomorphic.js: 0.2.5 - - lib0@0.2.114: - dependencies: - isomorphic.js: 0.2.5 - - lib0@0.2.88: + lib0@0.2.117: dependencies: isomorphic.js: 0.2.5 @@ -18784,8 +18732,6 @@ snapshots: lodash.defaults@4.2.0: {} - lodash.flatten@4.4.0: {} - lodash.includes@4.3.0: {} lodash.isarguments@3.1.0: {} @@ -19209,7 +19155,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 optional: true - msgpackr@1.11.2: + msgpackr@1.11.8: optionalDependencies: msgpackr-extract: 3.0.2 @@ -19249,6 +19195,13 @@ snapshots: kysely: 0.28.2 reflect-metadata: 0.2.2 + nestjs-pino@4.5.0(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.1.0 + pino-http: 11.0.0 + rxjs: 7.8.2 + next@14.2.10(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.51.0): dependencies: '@next/env': 14.2.10 @@ -19417,8 +19370,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 - obuf@1.1.2: {} - oidc-token-hash@5.0.3: {} ollama@0.6.3: @@ -19522,8 +19473,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@2.1.0: {} - p-queue@6.6.2: dependencies: eventemitter3: 4.0.7 @@ -19645,19 +19594,19 @@ snapshots: pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.9.1: {} + pg-connection-string@2.9.1: + optional: true - pg-int8@1.0.1: {} - - pg-numeric@1.0.2: {} + pg-int8@1.0.1: + optional: true pg-pool@3.10.1(pg@8.16.3): dependencies: pg: 8.16.3 + optional: true - pg-protocol@1.10.3: {} - - pg-protocol@1.7.0: {} + pg-protocol@1.10.3: + optional: true pg-tsquery@8.4.2: {} @@ -19668,16 +19617,7 @@ snapshots: postgres-bytea: 1.0.0 postgres-date: 1.0.7 postgres-interval: 1.2.0 - - pg-types@4.0.2: - dependencies: - pg-int8: 1.0.1 - pg-numeric: 1.0.2 - postgres-array: 3.0.2 - postgres-bytea: 3.0.0 - postgres-date: 2.1.0 - postgres-interval: 3.0.0 - postgres-range: 1.1.4 + optional: true pg@8.16.3: dependencies: @@ -19688,10 +19628,12 @@ snapshots: pgpass: 1.0.5 optionalDependencies: pg-cloudflare: 1.2.7 + optional: true pgpass@1.0.5: dependencies: split2: 4.2.0 + optional: true pgvector@0.2.1: {} @@ -19722,6 +19664,33 @@ snapshots: dependencies: split2: 4.2.0 + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.1.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.3 + secure-json-parse: 4.0.0 + sonic-boom: 4.0.1 + strip-json-comments: 5.0.3 + pino-std-serializers@7.0.0: {} pino@10.1.0: @@ -19833,27 +19802,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} + postgres-array@2.0.0: + optional: true - postgres-array@3.0.2: {} + postgres-bytea@1.0.0: + optional: true - postgres-bytea@1.0.0: {} - - postgres-bytea@3.0.0: - dependencies: - obuf: 1.1.2 - - postgres-date@1.0.7: {} - - postgres-date@2.1.0: {} + postgres-date@1.0.7: + optional: true postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + optional: true - postgres-interval@3.0.0: {} - - postgres-range@1.1.4: {} + postgres@3.4.8: {} posthog-js@1.255.1: dependencies: @@ -19968,7 +19931,7 @@ snapshots: dependencies: prosemirror-model: 1.25.1 - prosemirror-schema-list@1.4.1: + prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 @@ -20013,6 +19976,11 @@ snapshots: prr@1.0.1: optional: true + pump@3.0.3: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -20268,18 +20236,12 @@ snapshots: dependencies: resolve: 1.22.8 - redis-commands@1.7.0: {} - redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - redlock@4.2.0: - dependencies: - bluebird: 3.7.2 - redux@4.2.1: dependencies: '@babel/runtime': 7.25.6 @@ -20805,6 +20767,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + stripe@17.5.0: dependencies: '@types/node': 22.19.1 @@ -20923,10 +20887,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tippy.js@6.3.7: - dependencies: - '@popperjs/core': 2.11.8 - tiptap-extension-global-drag-handle@0.1.18: {} tldts-core@6.1.72: {} @@ -21077,6 +21037,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tseep@1.3.1: {} + tslib@2.8.0: {} tslib@2.8.1: {} @@ -21267,6 +21229,10 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + utf8-byte-length@1.0.4: {} util-deprecate@1.0.2: {} @@ -21541,26 +21507,27 @@ snapshots: xpath@0.0.34: {} - xtend@4.0.2: {} + xtend@4.0.2: + optional: true - y-indexeddb@9.0.12(yjs@13.6.27): + y-indexeddb@9.0.12(yjs@13.6.29): dependencies: - lib0: 0.2.88 - yjs: 13.6.27 + lib0: 0.2.117 + yjs: 13.6.29 - y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): + y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29): dependencies: - lib0: 0.2.114 + lib0: 0.2.117 prosemirror-model: 1.25.1 prosemirror-state: 1.4.3 prosemirror-view: 1.40.0 - y-protocols: 1.0.6(yjs@13.6.27) - yjs: 13.6.27 + y-protocols: 1.0.6(yjs@13.6.29) + yjs: 13.6.29 - y-protocols@1.0.6(yjs@13.6.27): + y-protocols@1.0.6(yjs@13.6.29): dependencies: - lib0: 0.2.114 - yjs: 13.6.27 + lib0: 0.2.117 + yjs: 13.6.29 y18n@4.0.3: {} @@ -21610,9 +21577,9 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 - yjs@13.6.27: + yjs@13.6.29: dependencies: - lib0: 0.2.108 + lib0: 0.2.117 yn@3.1.1: {} @@ -21622,11 +21589,6 @@ snapshots: yoctocolors-cjs@2.1.2: {} - zeed-dom@0.15.1: - dependencies: - css-what: 6.1.0 - entities: 5.0.0 - zod@3.25.76: {} zod@4.3.5: {}