mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 15:53:10 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d50558db | |||
| 605bf760be | |||
| c5c9237b00 | |||
| 511c822a90 | |||
| 7af31fe228 | |||
| dd460713ce | |||
| b0f3bec4d1 | |||
| 8ea92668b2 | |||
| dcf9228344 | |||
| dbd1308c72 | |||
| 03ae58253a | |||
| b99d803b81 | |||
| b9b5ddb848 | |||
| dd8c42e1f3 | |||
| 13c29545a2 | |||
| 0c5c83a17a | |||
| 5bda5623f2 | |||
| 051bc80ab7 | |||
| 78d363febb | |||
| 8681d9a8c4 | |||
| b9543b01bd | |||
| 5510434221 | |||
| f671e7a3b9 | |||
| 974bcea690 | |||
| 601ed88931 | |||
| cfbaedcd63 | |||
| 5fc04aa7df | |||
| c357f169e1 | |||
| 1cbd2854bb | |||
| 3af1482a31 | |||
| d31d1f7bbd | |||
| cc0146d0cd | |||
| 83ce9cf240 | |||
| e7e85e9fdd | |||
| 2d710612b1 | |||
| a0814ef49a | |||
| bf17289ab2 | |||
| c2cd412ac7 | |||
| 71dfcf6bce | |||
| 23e8ab032e | |||
| 0e1d4e5eee | |||
| 6f83f32d5c | |||
| 63ea2f7663 | |||
| 66a3dad632 | |||
| 2adc6a60d2 |
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@@ -42,15 +42,15 @@
|
|||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "^1.255.1",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.3",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.17",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-drawio": "^1.0.7",
|
"react-drawio": "^1.0.7",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^6.1.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
@@ -63,13 +63,13 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "22.19.1",
|
"@types/node": "22.19.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
"Choose your preferred interface language.": "Wählen Sie Ihre bevorzugte Benutzersprache.",
|
||||||
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
"Choose your preferred page width.": "Wählen Sie Ihre bevorzugte Seitenbreite.",
|
||||||
"Confirm": "Bestätigen",
|
"Confirm": "Bestätigen",
|
||||||
"Copy as Markdown": "Als Markdown kopieren",
|
|
||||||
"Copy link": "Link kopieren",
|
"Copy link": "Link kopieren",
|
||||||
"Create": "Erstellen",
|
"Create": "Erstellen",
|
||||||
"Create group": "Gruppe erstellen",
|
"Create group": "Gruppe erstellen",
|
||||||
@@ -235,9 +234,7 @@
|
|||||||
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
|
||||||
"Invite link": "Einladungslink",
|
"Invite link": "Einladungslink",
|
||||||
"Copy": "Kopieren",
|
"Copy": "Kopieren",
|
||||||
"Copy to space": "In Raum kopieren",
|
|
||||||
"Copied": "Kopiert",
|
"Copied": "Kopiert",
|
||||||
"Duplicate": "Duplizieren",
|
|
||||||
"Select a user": "Benutzer auswählen",
|
"Select a user": "Benutzer auswählen",
|
||||||
"Select a group": "Gruppe 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.",
|
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||||
@@ -254,7 +251,6 @@
|
|||||||
"Export failed:": "Export fehlgeschlagen:",
|
"Export failed:": "Export fehlgeschlagen:",
|
||||||
"export error": "Exportfehler",
|
"export error": "Exportfehler",
|
||||||
"Export page": "Seite exportieren",
|
"Export page": "Seite exportieren",
|
||||||
"Export successful": "Export erfolgreich",
|
|
||||||
"Export space": "Bereich exportieren",
|
"Export space": "Bereich exportieren",
|
||||||
"Export {{type}}": "Exportiere {{type}}",
|
"Export {{type}}": "Exportiere {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
|
||||||
@@ -330,8 +326,6 @@
|
|||||||
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
|
"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 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.",
|
"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",
|
"Table": "Tabelle",
|
||||||
"Insert a table.": "Tabelle einfügen.",
|
"Insert a table.": "Tabelle einfügen.",
|
||||||
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
"Insert collapsible block.": "Einklappbaren Block einfügen.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
"Choose your preferred interface language.": "Choose your preferred interface language.",
|
||||||
"Choose your preferred page width.": "Choose your preferred page width.",
|
"Choose your preferred page width.": "Choose your preferred page width.",
|
||||||
"Confirm": "Confirm",
|
"Confirm": "Confirm",
|
||||||
"Copy as Markdown": "Copy as Markdown",
|
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Create": "Create",
|
"Create": "Create",
|
||||||
"Create group": "Create group",
|
"Create group": "Create group",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Export failed:",
|
"Export failed:": "Export failed:",
|
||||||
"export error": "export error",
|
"export error": "export error",
|
||||||
"Export page": "Export page",
|
"Export page": "Export page",
|
||||||
"Export successful": "Export successful",
|
|
||||||
"Export space": "Export space",
|
"Export space": "Export space",
|
||||||
"Export {{type}}": "Export {{type}}",
|
"Export {{type}}": "Export {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
"Choose your preferred interface language.": "Elige tu idioma de interfaz preferido.",
|
||||||
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
"Choose your preferred page width.": "Elige el ancho de página que prefieras.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
"Copy as Markdown": "Copiar como Markdown",
|
|
||||||
"Copy link": "Copiar enlace",
|
"Copy link": "Copiar enlace",
|
||||||
"Create": "Crear",
|
"Create": "Crear",
|
||||||
"Create group": "Crear grupo",
|
"Create group": "Crear grupo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Exportación fallida:",
|
"Export failed:": "Exportación fallida:",
|
||||||
"export error": "error de exportación",
|
"export error": "error de exportación",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
"Export successful": "Exportación exitosa",
|
|
||||||
"Export space": "Exportar espacio",
|
"Export space": "Exportar espacio",
|
||||||
"Export {{type}}": "Exportar {{type}}",
|
"Export {{type}}": "Exportar {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
|
"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 video from your device.": "Sube cualquier video desde tu dispositivo.",
|
||||||
"Upload any file from your device.": "Sube cualquier archivo 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",
|
"Table": "Tabla",
|
||||||
"Insert a table.": "Insertar una tabla.",
|
"Insert a table.": "Insertar una tabla.",
|
||||||
"Insert collapsible block.": "Insertar bloque desplegable.",
|
"Insert collapsible block.": "Insertar bloque desplegable.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Choisissez votre langue d'interface préférée.",
|
"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.",
|
"Choose your preferred page width.": "Choisissez votre largeur de page préférée.",
|
||||||
"Confirm": "Confirmer",
|
"Confirm": "Confirmer",
|
||||||
"Copy as Markdown": "Copier comme Markdown",
|
|
||||||
"Copy link": "Copier le lien",
|
"Copy link": "Copier le lien",
|
||||||
"Create": "Créer",
|
"Create": "Créer",
|
||||||
"Create group": "Créer groupe",
|
"Create group": "Créer groupe",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Échec de l'exportation :",
|
"Export failed:": "Échec de l'exportation :",
|
||||||
"export error": "exporter l'erreur",
|
"export error": "exporter l'erreur",
|
||||||
"Export page": "Exporter la page",
|
"Export page": "Exporter la page",
|
||||||
"Export successful": "Exportation réussie",
|
|
||||||
"Export space": "Exporter l'espace",
|
"Export space": "Exporter l'espace",
|
||||||
"Export {{type}}": "Exporter {{type}}",
|
"Export {{type}}": "Exporter {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
|
"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 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.",
|
"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",
|
"Table": "Tableau",
|
||||||
"Insert a table.": "Insérez un tableau.",
|
"Insert a table.": "Insérez un tableau.",
|
||||||
"Insert collapsible block.": "Insérer un bloc repliable.",
|
"Insert collapsible block.": "Insérer un bloc repliable.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
"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.",
|
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||||
"Confirm": "Conferma",
|
"Confirm": "Conferma",
|
||||||
"Copy as Markdown": "Copia come Markdown",
|
|
||||||
"Copy link": "Copia link",
|
"Copy link": "Copia link",
|
||||||
"Create": "Crea",
|
"Create": "Crea",
|
||||||
"Create group": "Crea gruppo",
|
"Create group": "Crea gruppo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Esportazione fallita:",
|
"Export failed:": "Esportazione fallita:",
|
||||||
"export error": "errore di esportazione",
|
"export error": "errore di esportazione",
|
||||||
"Export page": "Esporta pagina",
|
"Export page": "Esporta pagina",
|
||||||
"Export successful": "Esportazione riuscita",
|
|
||||||
"Export space": "Esporta spazio",
|
"Export space": "Esporta spazio",
|
||||||
"Export {{type}}": "Esporta {{type}}",
|
"Export {{type}}": "Esporta {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
|
"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 video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
|
||||||
"Upload any file from your device.": "Carica qualsiasi file 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",
|
"Table": "Tabella",
|
||||||
"Insert a table.": "Inserisci una tabella.",
|
"Insert a table.": "Inserisci una tabella.",
|
||||||
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
"Insert collapsible block.": "Inserisci blocco comprimibile.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"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": "Markdownとしてコピー",
|
|
||||||
"Copy link": "リンクをコピー",
|
"Copy link": "リンクをコピー",
|
||||||
"Create": "新規作成",
|
"Create": "新規作成",
|
||||||
"Create group": "グループを作成",
|
"Create group": "グループを作成",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "エクスポートに失敗しました:",
|
"Export failed:": "エクスポートに失敗しました:",
|
||||||
"export error": "エクスポートエラー",
|
"export error": "エクスポートエラー",
|
||||||
"Export page": "エクスポートページ",
|
"Export page": "エクスポートページ",
|
||||||
"Export successful": "エクスポート成功",
|
|
||||||
"Export space": "エクスポートスペース",
|
"Export space": "エクスポートスペース",
|
||||||
"Export {{type}}": "{{type}}をエクスポート",
|
"Export {{type}}": "{{type}}をエクスポート",
|
||||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"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}}": "{{name}} をアップロード中",
|
|
||||||
"Uploading file": "ファイルをアップロード中",
|
|
||||||
"Table": "テーブル",
|
"Table": "テーブル",
|
||||||
"Insert a table.": "テーブルを挿入します",
|
"Insert a table.": "テーブルを挿入します",
|
||||||
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
"Insert collapsible block.": "折りたたみブロックを挿入します",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"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": "Markdown으로 복사",
|
|
||||||
"Copy link": "링크 복사",
|
"Copy link": "링크 복사",
|
||||||
"Create": "생성",
|
"Create": "생성",
|
||||||
"Create group": "팀 생성",
|
"Create group": "팀 생성",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "내보내기 실패:",
|
"Export failed:": "내보내기 실패:",
|
||||||
"export error": "내보내기 오류",
|
"export error": "내보내기 오류",
|
||||||
"Export page": "페이지 내보내기",
|
"Export page": "페이지 내보내기",
|
||||||
"Export successful": "내보내기 성공",
|
|
||||||
"Export space": "Space 내보내기",
|
"Export space": "Space 내보내기",
|
||||||
"Export {{type}}": "{{type}} 내보내기",
|
"Export {{type}}": "{{type}} 내보내기",
|
||||||
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"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}}": "{{name}} 업로드 중",
|
|
||||||
"Uploading file": "파일 업로드 중",
|
|
||||||
"Table": "테이블",
|
"Table": "테이블",
|
||||||
"Insert a table.": "테이블 삽입.",
|
"Insert a table.": "테이블 삽입.",
|
||||||
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
"Insert collapsible block.": "접을 수 있는 블록 삽입.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
|
||||||
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
|
||||||
"Confirm": "Bevestig",
|
"Confirm": "Bevestig",
|
||||||
"Copy as Markdown": "Kopiëren als Markdown",
|
|
||||||
"Copy link": "Link kopiëren",
|
"Copy link": "Link kopiëren",
|
||||||
"Create": "Aanmaken",
|
"Create": "Aanmaken",
|
||||||
"Create group": "Groep aanmaken",
|
"Create group": "Groep aanmaken",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Exporteren mislukt:",
|
"Export failed:": "Exporteren mislukt:",
|
||||||
"export error": "Exporteer fout",
|
"export error": "Exporteer fout",
|
||||||
"Export page": "Exporteer pagina",
|
"Export page": "Exporteer pagina",
|
||||||
"Export successful": "Export succesvol",
|
|
||||||
"Export space": "Exporteer ruimte",
|
"Export space": "Exporteer ruimte",
|
||||||
"Export {{type}}": "Exporteer {{type}}",
|
"Export {{type}}": "Exporteer {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
|
"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 video from your device.": "Upload een video vanaf uw apparaat.",
|
||||||
"Upload any file from your device.": "Upload een bestand 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",
|
"Table": "Tabel",
|
||||||
"Insert a table.": "Voeg een tabel in.",
|
"Insert a table.": "Voeg een tabel in.",
|
||||||
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
"Insert collapsible block.": "Inklapbaar blok invoegen.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
"Choose your preferred interface language.": "Escolha o idioma da interface.",
|
||||||
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
"Choose your preferred page width.": "Escolha a largura preferida da página.",
|
||||||
"Confirm": "Confirmar",
|
"Confirm": "Confirmar",
|
||||||
"Copy as Markdown": "Copiar como Markdown",
|
|
||||||
"Copy link": "Copiar link",
|
"Copy link": "Copiar link",
|
||||||
"Create": "Criar",
|
"Create": "Criar",
|
||||||
"Create group": "Criar grupo",
|
"Create group": "Criar grupo",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Falha ao exportar:",
|
"Export failed:": "Falha ao exportar:",
|
||||||
"export error": "erro de exportação",
|
"export error": "erro de exportação",
|
||||||
"Export page": "Exportar página",
|
"Export page": "Exportar página",
|
||||||
"Export successful": "Exportação bem-sucedida",
|
|
||||||
"Export space": "Exportar espaço",
|
"Export space": "Exportar espaço",
|
||||||
"Export {{type}}": "Exportar para {{type}}",
|
"Export {{type}}": "Exportar para {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
|
"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 video from your device.": "Envie qualquer vídeo do seu dispositivo.",
|
||||||
"Upload any file from your device.": "Envie qualquer arquivo 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",
|
"Table": "Tabela",
|
||||||
"Insert a table.": "Insira uma tabela.",
|
"Insert a table.": "Insira uma tabela.",
|
||||||
"Insert collapsible block.": "Insira um bloco colapsável.",
|
"Insert collapsible block.": "Insira um bloco colapsável.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"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": "Копировать как Markdown",
|
|
||||||
"Copy link": "Копировать ссылку",
|
"Copy link": "Копировать ссылку",
|
||||||
"Create": "Создать",
|
"Create": "Создать",
|
||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Экспортирование не удалось:",
|
"Export failed:": "Экспортирование не удалось:",
|
||||||
"export error": "ошибка экспорта",
|
"export error": "ошибка экспорта",
|
||||||
"Export page": "Экспорт страницы",
|
"Export page": "Экспорт страницы",
|
||||||
"Export successful": "Экспорт выполнен успешно",
|
|
||||||
"Export space": "Экспорт пространства",
|
"Export space": "Экспорт пространства",
|
||||||
"Export {{type}}": "Экспорт {{type}}",
|
"Export {{type}}": "Экспорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"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}}": "Загрузка {{name}}",
|
|
||||||
"Uploading file": "Загрузка файла",
|
|
||||||
"Table": "Таблица",
|
"Table": "Таблица",
|
||||||
"Insert a table.": "Вставить таблицу.",
|
"Insert a table.": "Вставить таблицу.",
|
||||||
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
"Insert collapsible block.": "Вставить сворачиваемый блок.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"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": "Скопіювати як Markdown",
|
|
||||||
"Copy link": "Копіювати посилання",
|
"Copy link": "Копіювати посилання",
|
||||||
"Create": "Створити",
|
"Create": "Створити",
|
||||||
"Create group": "Створити групу",
|
"Create group": "Створити групу",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "Експортування не вдалося:",
|
"Export failed:": "Експортування не вдалося:",
|
||||||
"export error": "помилка експорту",
|
"export error": "помилка експорту",
|
||||||
"Export page": "Експорт сторінки",
|
"Export page": "Експорт сторінки",
|
||||||
"Export successful": "Експорт виконано успішно",
|
|
||||||
"Export space": "Експорт простору",
|
"Export space": "Експорт простору",
|
||||||
"Export {{type}}": "Експорт {{type}}",
|
"Export {{type}}": "Експорт {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"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}}": "Завантаження {{name}}",
|
|
||||||
"Uploading file": "Завантаження файлу",
|
|
||||||
"Table": "Таблиця",
|
"Table": "Таблиця",
|
||||||
"Insert a table.": "Вставити таблицю.",
|
"Insert a table.": "Вставити таблицю.",
|
||||||
"Insert collapsible block.": "Вставити блок, що згортається.",
|
"Insert collapsible block.": "Вставити блок, що згортається.",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
"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": "复制为Markdown",
|
|
||||||
"Copy link": "复制链接",
|
"Copy link": "复制链接",
|
||||||
"Create": "创建",
|
"Create": "创建",
|
||||||
"Create group": "创建群组",
|
"Create group": "创建群组",
|
||||||
@@ -254,7 +253,6 @@
|
|||||||
"Export failed:": "导出失败:",
|
"Export failed:": "导出失败:",
|
||||||
"export error": "导出出错",
|
"export error": "导出出错",
|
||||||
"Export page": "导出页面",
|
"Export page": "导出页面",
|
||||||
"Export successful": "导出成功",
|
|
||||||
"Export space": "导出空间",
|
"Export space": "导出空间",
|
||||||
"Export {{type}}": "导出为 {{type}}",
|
"Export {{type}}": "导出为 {{type}}",
|
||||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||||
@@ -330,8 +328,6 @@
|
|||||||
"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}}": "正在上传{{name}}",
|
|
||||||
"Uploading file": "正在上传文件",
|
|
||||||
"Table": "表格",
|
"Table": "表格",
|
||||||
"Insert a table.": "插入一个表格",
|
"Insert a table.": "插入一个表格",
|
||||||
"Insert collapsible block.": "插入一个折叠块",
|
"Insert collapsible block.": "插入一个折叠块",
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ export default function ExportModal({
|
|||||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||||
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
|
||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
await exportPage({
|
||||||
@@ -47,9 +45,6 @@ export default function ExportModal({
|
|||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
}
|
}
|
||||||
notifications.show({
|
|
||||||
message: t("Export successful"),
|
|
||||||
});
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -57,8 +52,6 @@ export default function ExportModal({
|
|||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,7 +136,7 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
<Button onClick={handleExport}>{t("Export")}</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface PagePaginationProps {
|
export interface PagePaginationProps {
|
||||||
|
currentPage: number;
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
onPrev: () => void;
|
onPageChange: (newPage: number) => void;
|
||||||
onNext: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Paginate({
|
export default function Paginate({
|
||||||
|
currentPage,
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onPrev,
|
onPageChange,
|
||||||
onNext,
|
|
||||||
}: PagePaginationProps) {
|
}: PagePaginationProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={onPrev}
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
disabled={!hasPrevPage}
|
disabled={!hasPrevPage}
|
||||||
>
|
>
|
||||||
{t("Prev")}
|
{t("Prev")}
|
||||||
@@ -34,7 +34,7 @@ export default function Paginate({
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={onNext}
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
disabled={!hasNextPage}
|
disabled={!hasNextPage}
|
||||||
>
|
>
|
||||||
{t("Next")}
|
{t("Next")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
|
|||||||
import { getApiKeys } from "@/ee/api-key";
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params: QueryParams = { limit: 100, query: "" };
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["workspaceMembers", params],
|
queryKey: ["workspaceMembers", params],
|
||||||
queryFn: () => getWorkspaceMembers(params),
|
queryFn: () => getWorkspaceMembers(params),
|
||||||
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
|
|||||||
|
|
||||||
export const prefetchSpaces = () => {
|
export const prefetchSpaces = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["spaces", {}],
|
queryKey: ["spaces", { page: 1 }],
|
||||||
queryFn: () => getSpaces({}),
|
queryFn: () => getSpaces({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchGroups = () => {
|
export const prefetchGroups = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groups", {}],
|
queryKey: ["groups", { page: 1 }],
|
||||||
queryFn: () => getGroups({}),
|
queryFn: () => getGroups({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
|
|||||||
|
|
||||||
export const prefetchShares = () => {
|
export const prefetchShares = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["share-list", {}],
|
queryKey: ["share-list", { page: 1 }],
|
||||||
queryFn: () => getShares({}),
|
queryFn: () => getShares({ page: 1, limit: 100 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeys = () => {
|
export const prefetchApiKeys = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", {}],
|
queryKey: ["api-key-list", { page: 1 }],
|
||||||
queryFn: () => getApiKeys({}),
|
queryFn: () => getApiKeys({ page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const prefetchApiKeyManagement = () => {
|
export const prefetchApiKeyManagement = () => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["api-key-list", { adminView: true }],
|
queryKey: ["api-key-list", { page: 1 }],
|
||||||
queryFn: () => getApiKeys({ adminView: true }),
|
queryFn: () => getApiKeys({ page: 1, adminView: true }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
|
|
||||||
export default function UserApiKeys() {
|
export default function UserApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
const { data, isLoading } = useGetApiKeysQuery({ page });
|
||||||
|
|
||||||
const handleCreateSuccess = (response: IApiKey) => {
|
const handleCreateSuccess = (response: IApiKey) => {
|
||||||
setCreatedApiKey(response);
|
setCreatedApiKey(response);
|
||||||
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
|
|||||||
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
|
||||||
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
|
||||||
import Paginate from "@/components/common/paginate";
|
import Paginate from "@/components/common/paginate";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
|
||||||
import { IApiKey } from "@/ee/api-key";
|
import { IApiKey } from "@/ee/api-key";
|
||||||
import useUserRole from '@/hooks/use-user-role.tsx';
|
import useUserRole from '@/hooks/use-user-role.tsx';
|
||||||
|
|
||||||
export default function WorkspaceApiKeys() {
|
export default function WorkspaceApiKeys() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
|
||||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
|
||||||
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
|
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.items.length === 0) {
|
if (data?.length === 0) {
|
||||||
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data?.items
|
{data
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
||||||
if (enabledDiff !== 0) return enabledDiff;
|
if (enabledDiff !== 0) return enabledDiff;
|
||||||
@@ -104,11 +104,7 @@ export default function SsoProviderList() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
|
||||||
color={"gray"}
|
|
||||||
variant="light"
|
|
||||||
style={{ whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
{provider.type.toUpperCase()}
|
{provider.type.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -138,41 +134,41 @@ export default function SsoProviderList() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
>
|
>
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Menu
|
<Menu
|
||||||
transitionProps={{ transition: "pop" }}
|
transitionProps={{ transition: "pop" }}
|
||||||
withArrow
|
withArrow
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
withinPortal
|
withinPortal
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="subtle" color="gray">
|
<ActionIcon variant="subtle" color="gray">
|
||||||
<IconDots size={16} />
|
<IconDots size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => handleEdit(provider)}
|
onClick={() => handleEdit(provider)}
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
>
|
>
|
||||||
{t("Edit")}
|
{t("Edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={() => openDeleteModal(provider.id)}
|
onClick={() => openDeleteModal(provider.id)}
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
||||||
>
|
>
|
||||||
{t("Delete")}
|
{t("Delete")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
} from "@/ee/security/services/security-service.ts";
|
} from "@/ee/security/services/security-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
|
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["sso-providers"],
|
queryKey: ["sso-providers"],
|
||||||
queryFn: () => getSsoProviders(),
|
queryFn: () => getSsoProviders(),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import api from "@/lib/api-client.ts";
|
import api from "@/lib/api-client.ts";
|
||||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
|
|
||||||
export async function getSsoProviderById(data: {
|
export async function getSsoProviderById(data: {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -9,8 +8,8 @@ export async function getSsoProviderById(data: {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
|
export async function getSsoProviders(): Promise<IAuthProvider[]> {
|
||||||
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
|
const req = await api.post<IAuthProvider[]>("/sso/providers");
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
const enterHandler = () => {
|
const enterHandler = () => {
|
||||||
if (!renderItems.length) return;
|
if (!renderItems.length) return;
|
||||||
if (renderItems[selectedIndex]?.entityType !== "header") {
|
if (renderItems[selectedIndex].entityType !== "header") {
|
||||||
selectItem(selectedIndex);
|
selectItem(selectedIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const mentionRenderItems = () => {
|
|||||||
{
|
{
|
||||||
placement: "bottom-start",
|
placement: "bottom-start",
|
||||||
middleware: [offset(0), flip(), shift()],
|
middleware: [offset(0), flip(), shift()],
|
||||||
},
|
}
|
||||||
).then(({ x, y }) => {
|
).then(({ x, y }) => {
|
||||||
Object.assign(element.style, {
|
Object.assign(element.style, {
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
@@ -86,7 +86,7 @@ const mentionRenderItems = () => {
|
|||||||
zIndex: "9999",
|
zIndex: "9999",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
@@ -115,30 +115,23 @@ const mentionRenderItems = () => {
|
|||||||
|
|
||||||
// destroy component if space is greater 3 without a match
|
// destroy component if space is greater 3 without a match
|
||||||
if (
|
if (
|
||||||
whitespaceCount > 4 &&
|
whitespaceCount > 3 &&
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
props.editor.storage.mentionItems.length === 1
|
props.editor.storage.mentionItems.length === 0
|
||||||
) {
|
) {
|
||||||
destroy();
|
destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// fallback exit
|
|
||||||
if (whitespaceCount > 7) {
|
|
||||||
destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key)
|
||||||
destroy();
|
if (
|
||||||
return true;
|
props.event.key === "Escape" ||
|
||||||
}
|
(props.event.key === "Enter" && !component)
|
||||||
|
) {
|
||||||
if (props.event.key === "Enter" && !component) {
|
destroy();
|
||||||
destroy();
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (component?.ref as any)?.onKeyDown(props);
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { JSX, useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { JSX, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { JSX, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const mainExtensions = [
|
|||||||
Escape: () => {
|
Escape: () => {
|
||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
return false;
|
return true;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
|
||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMemberCount } from "@/lib";
|
import { formatMemberCount } from "@/lib";
|
||||||
import { IGroup } from "@/features/group/types/group.types.ts";
|
import { IGroup } from "@/features/group/types/group.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetGroupsQuery({ cursor });
|
const { data, isLoading } = useGetGroupsQuery({ page });
|
||||||
|
|
||||||
const prefetchGroupMembers = (groupId: string) => {
|
const prefetchGroupMembers = (groupId: string) => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["groupMembers", groupId, {}],
|
queryKey: ["groupMembers", groupId, { page: 1 }],
|
||||||
queryFn: () => getGroupMembers(groupId, {}),
|
queryFn: () => getGroupMembers(groupId, { page: 1 }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,10 +85,10 @@ export default function GroupList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useRemoveGroupMemberMutation,
|
useRemoveGroupMemberMutation,
|
||||||
} from "@/features/group/queries/group-query";
|
} from "@/features/group/queries/group-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { IconDots } from "@tabler/icons-react";
|
import { IconDots } from "@tabler/icons-react";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
@@ -12,13 +12,12 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IUser } from "@/features/user/types/user.types.ts";
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
|
|
||||||
export default function GroupMembersList() {
|
export default function GroupMembersList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
|
||||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
@@ -108,10 +107,10 @@ export default function GroupMembersList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import '@/features/editor/styles/index.css';
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from 'react';
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
import { mainExtensions } from '@/features/editor/extensions/extensions';
|
||||||
import { Title } from "@mantine/core";
|
import { Title } from '@mantine/core';
|
||||||
import classes from "./history.module.css";
|
|
||||||
|
|
||||||
export interface HistoryEditorProps {
|
export interface HistoryEditorProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,9 +26,7 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Title order={1}>{title}</Title>
|
<Title order={1}>{title}</Title>
|
||||||
|
|
||||||
{editor && (
|
{editor && <EditorContent editor={editor} />}
|
||||||
<EditorContent editor={editor} className={classes.historyEditor} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +1,37 @@
|
|||||||
.history {
|
.history {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-md);
|
||||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
@mixin hover {
|
@mixin hover {
|
||||||
background-color: light-dark(
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||||
var(--mantine-color-gray-2),
|
}
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyEditor {
|
|
||||||
:global(.ProseMirror) {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background-color: light-dark(
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
|
||||||
var(--mantine-color-gray-2),
|
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
max-height: rem(700px);
|
max-height: rem(700px);
|
||||||
width: rem(250px);
|
width: rem(250px);
|
||||||
padding: var(--mantine-spacing-sm);
|
padding: var(--mantine-spacing-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: rem(1px) solid
|
border-right: rem(1px) solid
|
||||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarFlex {
|
.sidebarFlex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarMain {
|
.sidebarMain {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarRightSection {
|
.sidebarRightSection {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: rem(16px) rem(40px);
|
padding: rem(16px) rem(40px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -29,7 +28,6 @@ import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.
|
|||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -62,7 +60,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
const event = new CustomEvent("closeFindDialogFromEditor", {});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
},
|
},
|
||||||
{ preventDefault: false },
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -131,15 +128,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
notifications.show({ message: t("Link copied") });
|
notifications.show({ message: t("Link copied") });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyAsMarkdown = () => {
|
|
||||||
if (!pageEditor) return;
|
|
||||||
const html = pageEditor.getHTML();
|
|
||||||
const markdown = htmlToMarkdown(html);
|
|
||||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
|
||||||
clipboard.copy(`${title}${markdown}`);
|
|
||||||
notifications.show({ message: t("Copied") });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.print();
|
window.print();
|
||||||
@@ -177,13 +165,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy link")}
|
{t("Copy link")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconMarkdown size={16} />}
|
|
||||||
onClick={handleCopyAsMarkdown}
|
|
||||||
>
|
|
||||||
{t("Copy as Markdown")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
@@ -299,9 +280,7 @@ function ConnectionWarning() {
|
|||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
const isDisconnected = ["disconnected", "connecting"].includes(yjsConnectionStatus);
|
||||||
yjsConnectionStatus,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isDisconnected) {
|
if (isDisconnected) {
|
||||||
if (!timeoutRef.current) {
|
if (!timeoutRef.current) {
|
||||||
|
|||||||
@@ -172,10 +172,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
queryKey: ["root-sidebar-pages", fileTask.spaceId],
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["recent-changes", fileTask.spaceId],
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "refetchRootTreeNodeEvent",
|
operation: "refetchRootTreeNodeEvent",
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ export function useDeletePageMutation() {
|
|||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateOnMovePage();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +253,12 @@ export function useGetSidebarPagesQuery(
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: ["sidebar-pages", data],
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
|
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
|
||||||
initialPageParam: undefined,
|
initialPageParam: 1,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +266,13 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
|||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
|
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: 1,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta?.nextCursor ?? undefined,
|
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,127 +458,17 @@ export function invalidateOnUpdatePage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCacheOnMovePage(
|
export function invalidateOnMovePage() {
|
||||||
spaceId: string,
|
//for move invalidate all sidebars for now (how to do???)
|
||||||
pageId: string,
|
//invalidate all root sidebar pages
|
||||||
oldParentId: string | null,
|
queryClient.invalidateQueries({
|
||||||
newParentId: string | null,
|
queryKey: ["root-sidebar-pages"],
|
||||||
pageData: Partial<IPage>,
|
});
|
||||||
) {
|
//invalidate all sub sidebar pages
|
||||||
// Remove page from old parent's cache
|
queryClient.invalidateQueries({
|
||||||
const oldQueryKey =
|
queryKey: ["sidebar-pages"],
|
||||||
oldParentId === null
|
});
|
||||||
? ["root-sidebar-pages", spaceId]
|
// ---
|
||||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
|
||||||
oldQueryKey,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.filter((item) => item.id !== pageId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update old parent's hasChildren flag if it has no more children
|
|
||||||
if (oldParentId !== null) {
|
|
||||||
const oldParentCache = queryClient.getQueryData<
|
|
||||||
InfiniteData<IPagination<IPage>>
|
|
||||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
|
||||||
|
|
||||||
const remainingChildren =
|
|
||||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
|
||||||
|
|
||||||
if (remainingChildren === 0) {
|
|
||||||
// Update hasChildren in all caches where old parent appears
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
|
||||||
key,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((item) =>
|
|
||||||
item.id === oldParentId
|
|
||||||
? { ...item, hasChildren: false }
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add page to new parent's cache
|
|
||||||
const newQueryKey =
|
|
||||||
newParentId === null
|
|
||||||
? ["root-sidebar-pages", spaceId]
|
|
||||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
|
||||||
newQueryKey,
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
|
|
||||||
// Check if page already exists in new location
|
|
||||||
const exists = old.pages.some((page) =>
|
|
||||||
page.items.some((item) => item.id === pageId),
|
|
||||||
);
|
|
||||||
if (exists) return old;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page, index) => {
|
|
||||||
if (index === old.pages.length - 1) {
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
items: [...page.items, pageData],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update new parent's hasChildren flag
|
|
||||||
if (newParentId !== null) {
|
|
||||||
const allSideBarMatches = queryClient.getQueriesData({
|
|
||||||
predicate: (query) =>
|
|
||||||
query.queryKey[0] === "root-sidebar-pages" ||
|
|
||||||
query.queryKey[0] === "sidebar-pages",
|
|
||||||
});
|
|
||||||
|
|
||||||
allSideBarMatches.forEach(([key]) => {
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
pages: old.pages.map((page) => ({
|
|
||||||
...page,
|
|
||||||
items: page.items.map((item) =>
|
|
||||||
item.id === newParentId ? { ...item, hasChildren: true } : item,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateOnDeletePage(pageId: string) {
|
export function invalidateOnDeletePage(pageId: string) {
|
||||||
|
|||||||
@@ -72,19 +72,22 @@ export async function getSidebarPages(
|
|||||||
export async function getAllSidebarPages(
|
export async function getAllSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
let cursor: string | undefined = undefined;
|
let page = 1;
|
||||||
|
let hasNextPage = false;
|
||||||
const pages: IPagination<IPage>[] = [];
|
const pages: IPagination<IPage>[] = [];
|
||||||
const pageParams: (string | undefined)[] = [];
|
const pageParams: number[] = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
|
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
|
||||||
|
|
||||||
const data: IPagination<IPage> = req.data;
|
const data: IPagination<IPage> = req.data;
|
||||||
pages.push(data);
|
pages.push(data);
|
||||||
pageParams.push(cursor);
|
pageParams.push(page);
|
||||||
|
|
||||||
cursor = data.meta.nextCursor ?? undefined;
|
hasNextPage = data.meta.hasNextPage;
|
||||||
} while (cursor);
|
|
||||||
|
page += 1;
|
||||||
|
} while (hasNextPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageParams,
|
pageParams,
|
||||||
@@ -115,14 +118,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
let decodedFileName = fileName;
|
saveAs(req.data, decodeURIComponent(fileName));
|
||||||
try {
|
|
||||||
decodedFileName = decodeURIComponent(fileName);
|
|
||||||
} catch (err) {
|
|
||||||
// fallback to raw filename
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAs(req.data, decodedFileName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importPage(file: File, spaceId: string) {
|
export async function importPage(file: File, spaceId: string) {
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ import { useState } from "react";
|
|||||||
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
|
||||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const { page, setPage } = usePaginateAndSearch();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
|
||||||
cursor, limit: 50
|
page, limit: 50
|
||||||
});
|
});
|
||||||
const restorePageMutation = useRestorePageMutation();
|
const restorePageMutation = useRestorePageMutation();
|
||||||
const deletePageMutation = useDeletePageMutation();
|
const deletePageMutation = useDeletePageMutation();
|
||||||
@@ -206,10 +206,10 @@ export default function Trash() {
|
|||||||
|
|
||||||
{deletedPages && deletedPages.items.length > 0 && (
|
{deletedPages && deletedPages.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={deletedPages.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={deletedPages.meta?.hasNextPage}
|
hasPrevPage={deletedPages.meta.hasPrevPage}
|
||||||
onNext={() => goNext(deletedPages.meta?.nextCursor)}
|
hasNextPage={deletedPages.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
spaceId,
|
spaceId,
|
||||||
});
|
});
|
||||||
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>(null);
|
||||||
const [isRootReady, setIsRootReady] = useState(false);
|
const [isRootReady, setIsRootReady] = useState(false);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef((element) => {
|
const mergedRef = useMergedRef((element) => {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
updateCacheOnMovePage,
|
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -176,25 +175,9 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
parentPageId: args.parentId,
|
parentPageId: args.parentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const draggedNode = args.dragNodes[0];
|
|
||||||
const nodeData = draggedNode.data as SpaceTreeNode;
|
|
||||||
const oldParentId = nodeData.parentPageId ?? null;
|
|
||||||
const pageData = {
|
|
||||||
id: nodeData.id,
|
|
||||||
slugId: nodeData.slugId,
|
|
||||||
title: nodeData.name,
|
|
||||||
icon: nodeData.icon,
|
|
||||||
position: newPosition,
|
|
||||||
spaceId: nodeData.spaceId,
|
|
||||||
parentPageId: args.parentId,
|
|
||||||
hasChildren: nodeData.hasChildren,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await movePageMutation.mutateAsync(payload);
|
await movePageMutation.mutateAsync(payload);
|
||||||
|
|
||||||
updateCacheOnMovePage(spaceId, draggedNodeId, oldParentId, args.parentId, pageData);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit({
|
emit({
|
||||||
operation: "moveTreeNode",
|
operation: "moveTreeNode",
|
||||||
@@ -202,10 +185,8 @@ export function useTreeMutation<T>(spaceId: string) {
|
|||||||
payload: {
|
payload: {
|
||||||
id: draggedNodeId,
|
id: draggedNodeId,
|
||||||
parentId: args.parentId,
|
parentId: args.parentId,
|
||||||
oldParentId,
|
|
||||||
index: args.index,
|
index: args.index,
|
||||||
position: newPosition,
|
position: newPosition,
|
||||||
pageData,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
|
|||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
cursor?: string;
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPageInput {
|
export interface IPageInput {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconSparkles,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
@@ -25,7 +26,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
|
|||||||
import { useLicense } from "@/ee/hooks/use-license";
|
import { useLicense } from "@/ee/hooks/use-license";
|
||||||
import classes from "./search-spotlight-filters.module.css";
|
import classes from "./search-spotlight-filters.module.css";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai/index";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
|
||||||
interface SearchSpotlightFiltersProps {
|
interface SearchSpotlightFiltersProps {
|
||||||
@@ -52,6 +53,7 @@ export function SearchSpotlightFilters({
|
|||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({
|
const { data: spacesData } = useGetSpacesQuery({
|
||||||
|
page: 1,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: debouncedSpaceQuery,
|
query: debouncedSpaceQuery,
|
||||||
});
|
});
|
||||||
@@ -263,9 +265,7 @@ export function SearchSpotlightFilters({
|
|||||||
contentType !== option.value &&
|
contentType !== option.value &&
|
||||||
handleFilterChange("contentType", option.value)
|
handleFilterChange("contentType", option.value)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
||||||
option.disabled || (isAiMode && option.value === "attachment")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Group flex="1" gap="xs">
|
<Group flex="1" gap="xs">
|
||||||
<div>
|
<div>
|
||||||
@@ -275,13 +275,11 @@ export function SearchSpotlightFilters({
|
|||||||
{t("Enterprise")}
|
{t("Enterprise")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!option.disabled &&
|
{!option.disabled && isAiMode && option.value === "attachment" && (
|
||||||
isAiMode &&
|
<Text size="xs" mt={4}>
|
||||||
option.value === "attachment" && (
|
{t("Ask AI not available for attachments")}
|
||||||
<Text size="xs" mt={4}>
|
</Text>
|
||||||
{t("Ask AI not available for attachments")}
|
)}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{contentType === option.value && <IconCheck size={20} />}
|
{contentType === option.value && <IconCheck size={20} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
export async function searchPage(
|
export async function searchPage(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<{ items: IPageSearch[] }>("/search", params);
|
const req = await api.post<IPageSearch[]>("/search", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchSuggestions(
|
export async function searchSuggestions(
|
||||||
@@ -24,13 +24,13 @@ export async function searchSuggestions(
|
|||||||
export async function searchShare(
|
export async function searchShare(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IPageSearch[]> {
|
): Promise<IPageSearch[]> {
|
||||||
const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
|
const req = await api.post<IPageSearch[]>("/search/share-search", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAttachments(
|
export async function searchAttachments(
|
||||||
params: IPageSearchParams,
|
params: IPageSearchParams,
|
||||||
): Promise<IAttachmentSearch[]> {
|
): Promise<IAttachmentSearch[]> {
|
||||||
const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
|
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params);
|
||||||
return req.data.items;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Table, Group, Text, Anchor } from "@mantine/core";
|
import { Table, Group, Text, Anchor } from "@mantine/core";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
import { ISharedItem } from "@/features/share/types/share.types.ts";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@@ -15,8 +14,8 @@ import classes from "./share.module.css";
|
|||||||
|
|
||||||
export default function ShareList() {
|
export default function ShareList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetSharesQuery({ cursor });
|
const { data, isLoading } = useGetSharesQuery({ page });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -87,10 +86,10 @@ export default function ShareList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
|
|||||||
const [tree, setTree] = useState<
|
const [tree, setTree] = useState<
|
||||||
TreeApi<SharedPageTreeNode> | null | undefined
|
TreeApi<SharedPageTreeNode> | null | undefined
|
||||||
>(null);
|
>(null);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>(null);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
|
|||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
): UseQueryResult<IPagination<ISharedItem>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["share-list", params],
|
queryKey: ["share-list"],
|
||||||
queryFn: () => getShares(params),
|
queryFn: () => getShares(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ISpace } from "../types/space.types";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import APP_ROUTE from "@/lib/app-route";
|
import APP_ROUTE from "@/lib/app-route";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface DeleteSpaceModalProps {
|
interface DeleteSpaceModalProps {
|
||||||
space: ISpace;
|
space: ISpace;
|
||||||
@@ -15,7 +14,6 @@ interface DeleteSpaceModalProps {
|
|||||||
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const deleteSpaceMutation = useDeleteSpaceMutation();
|
const deleteSpaceMutation = useDeleteSpaceMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -37,15 +35,12 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
try {
|
||||||
// pass slug too so we can clear the local cache
|
// pass slug too so we can clear the local cache
|
||||||
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
|
||||||
navigate(APP_ROUTE.HOME);
|
navigate(APP_ROUTE.HOME);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete space", error);
|
console.error("Failed to delete space", error);
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +79,7 @@ export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
|
|||||||
<Button onClick={close} variant="default">
|
<Button onClick={close} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDelete} color="red" loading={isDeleting}>
|
<Button onClick={handleDelete} color="red">
|
||||||
{t("Confirm")}
|
{t("Confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { SpaceSelect } from "./space-select";
|
import { SpaceSelect } from "./space-select";
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Button, Popover, Text } from "@mantine/core";
|
import { Button, Popover, Text } from "@mantine/core";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
@@ -21,7 +21,7 @@ export function SwitchSpace({
|
|||||||
spaceIcon,
|
spaceIcon,
|
||||||
}: SwitchSpaceProps) {
|
}: SwitchSpaceProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [opened, { close, toggle }] = useDisclosure(false);
|
const [opened, { close, open, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -44,9 +44,9 @@ export function SwitchSpace({
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
rightSection={<IconChevronDown size={18} />}
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={toggle}
|
onClick={open}
|
||||||
>
|
>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
name={spaceName}
|
name={spaceName}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
|
|||||||
|
|
||||||
export default function SpaceGrid() {
|
export default function SpaceGrid() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
|
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 });
|
||||||
|
|
||||||
const cards = data?.items.slice(0, 9).map((space, index) => (
|
const cards = data?.items.slice(0, 9).map((space, index) => (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Group, Table, Text } from "@mantine/core";
|
import { Group, Table, Text } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
@@ -13,8 +12,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useGetSpacesQuery({ cursor });
|
const { data, isLoading } = useGetSpacesQuery({ page });
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
|
||||||
|
|
||||||
@@ -73,10 +72,10 @@ export default function SpaceList() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ export default function SpaceMembersList({
|
|||||||
readOnly,
|
readOnly,
|
||||||
}: SpaceMembersProps) {
|
}: SpaceMembersProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -206,10 +206,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
|||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
|
page: number;
|
||||||
hasPrevPage?: boolean;
|
hasPrevPage?: boolean;
|
||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
onNext: () => void;
|
onPageChange: (page: number) => void;
|
||||||
onPrev: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AllSpacesList({
|
export default function AllSpacesList({
|
||||||
spaces,
|
spaces,
|
||||||
onSearch,
|
onSearch,
|
||||||
|
page,
|
||||||
hasPrevPage,
|
hasPrevPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
onNext,
|
onPageChange,
|
||||||
onPrev,
|
|
||||||
}: AllSpacesListProps) {
|
}: AllSpacesListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
const [settingsOpened, { open: openSettings, close: closeSettings }] =
|
||||||
@@ -145,10 +145,10 @@ export default function AllSpacesList({
|
|||||||
|
|
||||||
{spaces.length > 0 && (
|
{spaces.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
hasPrevPage={hasPrevPage}
|
hasPrevPage={hasPrevPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
onNext={onNext}
|
onPageChange={onPageChange}
|
||||||
onPrev={onPrev}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,5 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
|
|||||||
.split("filename=")[1]
|
.split("filename=")[1]
|
||||||
.replace(/"/g, "");
|
.replace(/"/g, "");
|
||||||
|
|
||||||
let decodedFileName = fileName;
|
saveAs(req.data, decodeURIComponent(fileName));
|
||||||
try {
|
|
||||||
decodedFileName = decodeURIComponent(fileName);
|
|
||||||
} catch (err) {
|
|
||||||
// fallback to raw filename
|
|
||||||
}
|
|
||||||
|
|
||||||
saveAs(req.data, decodedFileName);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ export type MoveTreeNodeEvent = {
|
|||||||
payload: {
|
payload: {
|
||||||
id: string;
|
id: string;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
oldParentId: string | null;
|
|
||||||
index: number;
|
index: number;
|
||||||
position: string;
|
position: string;
|
||||||
pageData: Partial<IPage>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IPagination } from "@/lib/types";
|
|||||||
import {
|
import {
|
||||||
invalidateOnCreatePage,
|
invalidateOnCreatePage,
|
||||||
invalidateOnDeletePage,
|
invalidateOnDeletePage,
|
||||||
updateCacheOnMovePage,
|
invalidateOnMovePage,
|
||||||
invalidateOnUpdatePage,
|
invalidateOnUpdatePage,
|
||||||
} from "../page/queries/page-query";
|
} from "../page/queries/page-query";
|
||||||
import { RQ_KEY } from "../comment/queries/comment-query";
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
||||||
@@ -41,13 +41,7 @@ export const useQuerySubscription = () => {
|
|||||||
invalidateOnCreatePage(data.payload.data);
|
invalidateOnCreatePage(data.payload.data);
|
||||||
break;
|
break;
|
||||||
case "moveTreeNode":
|
case "moveTreeNode":
|
||||||
updateCacheOnMovePage(
|
invalidateOnMovePage();
|
||||||
data.spaceId,
|
|
||||||
data.payload.id,
|
|
||||||
data.payload.oldParentId,
|
|
||||||
data.payload.parentId,
|
|
||||||
data.payload.pageData,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "deleteTreeNode":
|
case "deleteTreeNode":
|
||||||
invalidateOnDeletePage(data.payload.node.id);
|
invalidateOnDeletePage(data.payload.node.id);
|
||||||
|
|||||||
+7
-8
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
|
||||||
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||||
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle } from "@tabler/icons-react";
|
||||||
@@ -8,13 +8,12 @@ import { timeAgo } from "@/lib/time.ts";
|
|||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
|
||||||
|
|
||||||
export default function WorkspaceInvitesTable() {
|
export default function WorkspaceInvitesTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
const { data, isLoading } = useWorkspaceInvitationsQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
@@ -66,10 +65,10 @@ export default function WorkspaceInvitesTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-6
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
|
|||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
const { data, isLoading } = useWorkspaceMembersQuery({
|
const { data, isLoading } = useWorkspaceMembersQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
|
|||||||
|
|
||||||
{data?.items.length > 0 && (
|
{data?.items.length > 0 && (
|
||||||
<Paginate
|
<Paginate
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
currentPage={page}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
onPrev={goPrev}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
export function useCursorPaginate() {
|
|
||||||
const [cursor, setCursor] = useState<string | undefined>(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 };
|
|
||||||
}
|
|
||||||
@@ -2,33 +2,16 @@ import { useState, useRef, useCallback } from "react";
|
|||||||
|
|
||||||
export function usePaginateAndSearch(initialQuery: string = "") {
|
export function usePaginateAndSearch(initialQuery: string = "") {
|
||||||
const [search, setSearch] = useState(initialQuery);
|
const [search, setSearch] = useState(initialQuery);
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const [page, setPage] = useState(1);
|
||||||
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
|
|
||||||
const prevSearchRef = useRef(search);
|
const prevSearchRef = useRef(search);
|
||||||
|
|
||||||
const handleSearch = useCallback((newQuery: string) => {
|
const handleSearch = useCallback((newQuery: string) => {
|
||||||
if (prevSearchRef.current !== newQuery) {
|
if (prevSearchRef.current !== newQuery) {
|
||||||
prevSearchRef.current = newQuery;
|
prevSearchRef.current = newQuery;
|
||||||
setSearch(newQuery);
|
setSearch(newQuery);
|
||||||
setCursor(undefined);
|
setPage(1);
|
||||||
setCursorStack([]);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goNext = useCallback((nextCursor: string | null | undefined) => {
|
return { search, page, setPage, handleSearch };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export interface QueryParams {
|
export interface QueryParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
cursor?: string;
|
page?: number;
|
||||||
beforeCursor?: string;
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
adminView?: boolean;
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
@@ -30,10 +29,9 @@ export interface ApiResponse<T> {
|
|||||||
|
|
||||||
export type IPaginationMeta = {
|
export type IPaginationMeta = {
|
||||||
limit: number;
|
limit: number;
|
||||||
|
page: number;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
hasPrevPage: boolean;
|
hasPrevPage: boolean;
|
||||||
nextCursor: string | null;
|
|
||||||
prevCursor: string | null;
|
|
||||||
};
|
};
|
||||||
export type IPagination<T> = {
|
export type IPagination<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
|
|||||||
export default function Spaces() {
|
export default function Spaces() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin } = useUserRole();
|
const { isAdmin } = useUserRole();
|
||||||
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
|
const { search, page, setPage, handleSearch } = usePaginateAndSearch();
|
||||||
|
|
||||||
const { data, isLoading } = useGetSpacesQuery({
|
const { data, isLoading } = useGetSpacesQuery({
|
||||||
cursor,
|
page,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
query: search,
|
query: search,
|
||||||
});
|
});
|
||||||
@@ -41,10 +41,10 @@ export default function Spaces() {
|
|||||||
<AllSpacesList
|
<AllSpacesList
|
||||||
spaces={data?.items || []}
|
spaces={data?.items || []}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
|
page={page}
|
||||||
hasPrevPage={data?.meta?.hasPrevPage}
|
hasPrevPage={data?.meta?.hasPrevPage}
|
||||||
hasNextPage={data?.meta?.hasNextPage}
|
hasNextPage={data?.meta?.hasNextPage}
|
||||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
onPageChange={setPage}
|
||||||
onPrev={goPrev}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.25.0-beta.1",
|
"version": "0.24.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -76,10 +76,8 @@
|
|||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
"kysely-postgres-js": "^3.0.0",
|
||||||
"ldapts": "^7.4.0",
|
"ldapts": "^7.4.0",
|
||||||
"lib0": "^0.2.117",
|
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"msgpackr": "^1.11.8",
|
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
"nestjs-pino": "^4.5.0",
|
"nestjs-pino": "^4.5.0",
|
||||||
@@ -104,7 +102,6 @@
|
|||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"stripe": "^17.5.0",
|
"stripe": "^17.5.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "^3.0.3",
|
||||||
"tseep": "^1.3.1",
|
|
||||||
"typesense": "^2.1.0",
|
"typesense": "^2.1.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
|
|||||||
@@ -30,20 +30,12 @@ export class CollabWsAdapter {
|
|||||||
return this.wss;
|
return this.wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
|
||||||
try {
|
|
||||||
this.wss.close();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
try {
|
try {
|
||||||
this.wss.close();
|
|
||||||
this.wss.clients.forEach((client) => {
|
this.wss.clients.forEach((client) => {
|
||||||
client.terminate();
|
client.terminate();
|
||||||
});
|
});
|
||||||
|
this.wss.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hocuspocus } from '@hocuspocus/server';
|
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Redis } from '@hocuspocus/extension-redis';
|
||||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
createRetryStrategy,
|
createRetryStrategy,
|
||||||
@@ -11,39 +12,19 @@ import {
|
|||||||
RedisConfig,
|
RedisConfig,
|
||||||
} from '../common/helpers';
|
} from '../common/helpers';
|
||||||
import { LoggerExtension } from './extensions/logger.extension';
|
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()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
private readonly hocuspocus: Hocuspocus;
|
private hocuspocus: Hocuspocus;
|
||||||
private redisConfig: RedisConfig;
|
private redisConfig: RedisConfig;
|
||||||
// @ts-ignore
|
|
||||||
private readonly redisSync: RedisSyncExtension<CollabEventHandlers> | null =
|
|
||||||
null;
|
|
||||||
private readonly withRedis: boolean;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authenticationExtension: AuthenticationExtension,
|
private authenticationExtension: AuthenticationExtension,
|
||||||
private persistenceExtension: PersistenceExtension,
|
private persistenceExtension: PersistenceExtension,
|
||||||
private loggerExtension: LoggerExtension,
|
private loggerExtension: LoggerExtension,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private collabEventsService: CollaborationHandler,
|
|
||||||
) {
|
) {
|
||||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
this.withRedis = !this.environmentService.isCollabDisableRedis();
|
|
||||||
|
|
||||||
this.hocuspocus = new Hocuspocus({
|
this.hocuspocus = new Hocuspocus({
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
@@ -53,80 +34,26 @@ export class CollaborationGateway {
|
|||||||
this.authenticationExtension,
|
this.authenticationExtension,
|
||||||
this.persistenceExtension,
|
this.persistenceExtension,
|
||||||
this.loggerExtension,
|
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 {
|
handleConnection(client: WebSocket, request: IncomingMessage): any {
|
||||||
if (this.redisSync) {
|
this.hocuspocus.handleConnection(client, request);
|
||||||
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() {
|
getConnectionCount() {
|
||||||
@@ -137,52 +64,7 @@ export class CollaborationGateway {
|
|||||||
return this.hocuspocus.getDocumentsCount();
|
return this.hocuspocus.getDocumentsCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleYjsEvent<TName extends keyof CollabEventHandlers>(
|
async destroy(): Promise<void> {
|
||||||
eventName: TName,
|
//await this.hocuspocus.destroy();
|
||||||
documentName: string,
|
|
||||||
payload: Parameters<CollabEventHandlers[TName]>[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<void> {
|
|
||||||
// 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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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<void> {
|
|
||||||
const connection = await hocuspocus.openDirectConnection(
|
|
||||||
documentName,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await connection.transact(fn);
|
|
||||||
} finally {
|
|
||||||
await connection.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { WebSocket } from 'ws';
|
|||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
import { HistoryListener } from './listeners/history.listener';
|
import { HistoryListener } from './listeners/history.listener';
|
||||||
import { LoggerExtension } from './extensions/logger.extension';
|
import { LoggerExtension } from './extensions/logger.extension';
|
||||||
import { CollaborationHandler } from './collaboration.handler';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -18,7 +17,6 @@ import { CollaborationHandler } from './collaboration.handler';
|
|||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
LoggerExtension,
|
LoggerExtension,
|
||||||
HistoryListener,
|
HistoryListener,
|
||||||
CollaborationHandler,
|
|
||||||
],
|
],
|
||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
imports: [TokenModule],
|
imports: [TokenModule],
|
||||||
@@ -48,12 +46,16 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wss.on('error', (error) =>
|
wss.on('error', (error) =>
|
||||||
this.logger.error('WebSocket server error:', error),
|
this.logger.log('WebSocket server error:', error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
await this.collaborationGateway?.destroy(this.collabWsAdapter);
|
if (this.collaborationGateway) {
|
||||||
this.collabWsAdapter?.destroy();
|
await this.collaborationGateway.destroy();
|
||||||
|
}
|
||||||
|
if (this.collabWsAdapter) {
|
||||||
|
this.collabWsAdapter.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
//import { generateJSON } from '@tiptap/html';
|
//import { generateJSON } from '@tiptap/html';
|
||||||
import { Node, Schema } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -111,53 +110,9 @@ export function jsonToText(tiptapJson: JSONContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToNode(tiptapJson: JSONContent) {
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
const schema = getSchema(tiptapExtensions);
|
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson);
|
||||||
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) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
export class LoggerExtension implements Extension {
|
export class LoggerExtension implements Extension {
|
||||||
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
private readonly logger = new Logger('Collab' + LoggerExtension.name);
|
||||||
|
|
||||||
|
async onDisconnect(data: onDisconnectPayload) {
|
||||||
|
this.logger.debug(`User disconnected from "${data.documentName}".`);
|
||||||
|
}
|
||||||
|
|
||||||
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
async afterUnloadDocument(data: onLoadDocumentPayload) {
|
||||||
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
this.logger.debug('Unloaded ' + data.documentName + ' from memory');
|
||||||
}
|
}
|
||||||
|
|
||||||
async onDisconnect(data: onDisconnectPayload) {
|
|
||||||
this.logger.debug('User disconnected from ' + data.documentName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './redis-sync.extension';
|
|
||||||
export type { SerializedHTTPRequest } from './redis-sync.extension';
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
// 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<TCE extends CustomEvents> implements Extension {
|
|
||||||
priority = 1000;
|
|
||||||
private readonly pub: RedisClient;
|
|
||||||
private sub: RedisClient;
|
|
||||||
private readonly pack: Pack;
|
|
||||||
private readonly unpack: Unpack;
|
|
||||||
private originSockets: Record<SocketId, BaseWebSocket> = {};
|
|
||||||
private locks: Record<DocumentName, NodeJS.Timeout> = {};
|
|
||||||
private lockPromises: Record<DocumentName, Promise<ServerId | null>> = {};
|
|
||||||
private proxySockets: Record<SocketId, CollabProxySocket> = {};
|
|
||||||
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<number, PromiseWithResolvers<any>['resolve']> =
|
|
||||||
{};
|
|
||||||
|
|
||||||
constructor(configuration: Configuration<TCE>) {
|
|
||||||
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<RSAMessageProxy, 'replyTo' | 'message' | 'serializedHTTPRequest'>,
|
|
||||||
) {
|
|
||||||
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<keyof TCE, string>,
|
|
||||||
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<TName extends Extract<keyof TCE, string>>(
|
|
||||||
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<ReturnType<TCE[TName]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleEvent<TName extends Extract<keyof TCE, string>>(
|
|
||||||
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<ReturnType<TCE[TName]>>;
|
|
||||||
}
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import EventEmitter from 'node:events';
|
|
||||||
import { IncomingHttpHeaders } from 'node:http2';
|
|
||||||
import RedisClient from 'ioredis';
|
|
||||||
|
|
||||||
export type SecondParam<T> = 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<ArrayBufferLike>;
|
|
||||||
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<ArrayBufferLike>;
|
|
||||||
socketId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RSAMessageCustomEventStart<TName = string, TPayload = unknown> = {
|
|
||||||
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<ArrayBufferLike>;
|
|
||||||
|
|
||||||
export type Unpack = (
|
|
||||||
// @ts-ignore
|
|
||||||
packedMessage: Uint8Array | Buffer<ArrayBufferLike>,
|
|
||||||
) => RSAMessage;
|
|
||||||
|
|
||||||
type ServerId = string;
|
|
||||||
type DocumentName = string;
|
|
||||||
type CustomEventName = string;
|
|
||||||
|
|
||||||
export type CustomEvents = Record<
|
|
||||||
CustomEventName,
|
|
||||||
(documentName: string, payload: unknown) => Promise<unknown>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface Configuration<TCE> {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,15 +12,12 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
CollabAppModule,
|
CollabAppModule,
|
||||||
new FastifyAdapter({
|
new FastifyAdapter({
|
||||||
routerOptions: {
|
ignoreTrailingSlash: true,
|
||||||
maxParamLength: 1000,
|
ignoreDuplicateSlashes: true,
|
||||||
ignoreTrailingSlash: true,
|
maxParamLength: 500,
|
||||||
ignoreDuplicateSlashes: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
logger: false,
|
bufferLogs: true,
|
||||||
bufferLogs: false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,8 +34,7 @@ async function bootstrap() {
|
|||||||
const logger = new Logger('CollabServer');
|
const logger = new Logger('CollabServer');
|
||||||
|
|
||||||
const port = process.env.COLLAB_PORT || 3001;
|
const port = process.env.COLLAB_PORT || 3001;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
await app.listen(port, '0.0.0.0', () => {
|
||||||
await app.listen(port, host, () => {
|
|
||||||
logger.log(`Listening on http://127.0.0.1:${port}`);
|
logger.log(`Listening on http://127.0.0.1:${port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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<string, ExportPageMetadata>;
|
|
||||||
};
|
|
||||||
@@ -5,14 +5,13 @@ const CONTEXTS_TO_IGNORE = [
|
|||||||
'InstanceLoader',
|
'InstanceLoader',
|
||||||
'RoutesResolver',
|
'RoutesResolver',
|
||||||
'RouterExplorer',
|
'RouterExplorer',
|
||||||
'LegacyRouteConverter',
|
|
||||||
'WebSocketsController',
|
'WebSocketsController',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createPinoConfig(): Params {
|
export function createPinoConfig(): Params {
|
||||||
const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true';
|
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||||
const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true';
|
const logHttp = process.env.LOG_HTTP === 'true';
|
||||||
|
|
||||||
const level = isProduction && !isDebugMode ? 'info' : 'debug';
|
const level = isProduction && !isDebugMode ? 'info' : 'debug';
|
||||||
|
|
||||||
@@ -33,20 +32,14 @@ export function createPinoConfig(): Params {
|
|||||||
: undefined,
|
: undefined,
|
||||||
formatters: {
|
formatters: {
|
||||||
level: (label) => ({ level: label }),
|
level: (label) => ({ level: label }),
|
||||||
},
|
log: (object: Record<string, unknown>) => {
|
||||||
hooks: {
|
|
||||||
logMethod(inputArgs, method) {
|
|
||||||
if (isProduction && !isDebugMode) {
|
if (isProduction && !isDebugMode) {
|
||||||
for (const arg of inputArgs) {
|
const context = object['context'] as string | undefined;
|
||||||
if (typeof arg === 'object' && arg !== null && 'context' in arg) {
|
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
|
||||||
const context = (arg as Record<string, unknown>)['context'];
|
return { filtered: true };
|
||||||
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return method.apply(this, inputArgs);
|
return object;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serializers: {
|
serializers: {
|
||||||
|
|||||||
@@ -181,9 +181,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.readStream(
|
const fileStream = await this.storageService.read(attachment.filePath);
|
||||||
attachment.filePath,
|
|
||||||
);
|
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': attachment.mimeType,
|
'Content-Type': attachment.mimeType,
|
||||||
'Cache-Control': 'private, max-age=3600',
|
'Cache-Control': 'private, max-age=3600',
|
||||||
@@ -243,9 +241,7 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.readStream(
|
const fileStream = await this.storageService.read(attachment.filePath);
|
||||||
attachment.filePath,
|
|
||||||
);
|
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': attachment.mimeType,
|
'Content-Type': attachment.mimeType,
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
@@ -371,14 +367,14 @@ export class AttachmentController {
|
|||||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = await this.storageService.readStream(filePath);
|
const fileStream = await this.storageService.read(filePath);
|
||||||
res.headers({
|
res.headers({
|
||||||
'Content-Type': getMimeType(filePath),
|
'Content-Type': getMimeType(filePath),
|
||||||
'Cache-Control': 'private, max-age=86400',
|
'Cache-Control': 'private, max-age=86400',
|
||||||
});
|
});
|
||||||
return res.send(fileStream);
|
return res.send(fileStream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// this.logger.error(err);
|
// this.logger.error(err);
|
||||||
throw new NotFoundException('File not found');
|
throw new NotFoundException('File not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
|
|||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
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 { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -66,14 +68,14 @@ export class CommentService {
|
|||||||
async findByPageId(
|
async findByPageId(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Comment>> {
|
): Promise<PaginationResult<Comment>> {
|
||||||
const page = await this.pageRepo.findById(pageId);
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new BadRequestException('Page not found');
|
throw new BadRequestException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.commentRepo.findPageComments(pageId, pagination);
|
return await this.commentRepo.findPageComments(pageId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto';
|
|||||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { GroupUserService } from './group-user.service';
|
import { GroupUserService } from './group-user.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -132,8 +132,12 @@ export class GroupService {
|
|||||||
async getWorkspaceGroups(
|
async getWorkspaceGroups(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
paginationOptions: PaginationOptions,
|
paginationOptions: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Group>> {
|
): Promise<PaginationResult<Group>> {
|
||||||
return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions);
|
const groups = await this.groupRepo.getGroupsPaginated(
|
||||||
|
workspaceId,
|
||||||
|
paginationOptions,
|
||||||
|
);
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
|
async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||||
import { PageHistory } from '@docmost/db/types/entity.types';
|
import { PageHistory } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageHistoryService {
|
export class PageHistoryService {
|
||||||
@@ -15,10 +15,12 @@ export class PageHistoryService {
|
|||||||
async findHistoryByPageId(
|
async findHistoryByPageId(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
paginationOptions: PaginationOptions,
|
paginationOptions: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<PageHistory>> {
|
): Promise<PaginationResult<any>> {
|
||||||
return this.pageHistoryRepo.findPageHistoryByPageId(
|
const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId(
|
||||||
pageId,
|
pageId,
|
||||||
paginationOptions,
|
paginationOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return pageHistory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import {
|
import {
|
||||||
CursorPaginationResult,
|
executeWithPagination,
|
||||||
executeWithCursorPagination,
|
PaginationResult,
|
||||||
} from '@docmost/db/pagination/cursor-pagination';
|
} from '@docmost/db/pagination/pagination';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
@@ -180,7 +180,7 @@ export class PageService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
pageId?: string,
|
pageId?: string,
|
||||||
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
|
): Promise<any> {
|
||||||
let query = this.db
|
let query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select([
|
.select([
|
||||||
@@ -195,6 +195,7 @@ export class PageService {
|
|||||||
'deletedAt',
|
'deletedAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||||
|
.orderBy('position', (ob) => ob.collate('C').asc())
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.where('spaceId', '=', spaceId);
|
.where('spaceId', '=', spaceId);
|
||||||
|
|
||||||
@@ -204,19 +205,12 @@ export class PageService {
|
|||||||
query = query.where('parentPageId', 'is', null);
|
query = query.where('parentPageId', 'is', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: 250,
|
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) {
|
async movePageToSpace(rootPage: Page, spaceId: string) {
|
||||||
@@ -265,7 +259,7 @@ export class PageService {
|
|||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||||
pageId: pageIds,
|
pageId: pageIds,
|
||||||
workspaceId: rootPage.workspaceId,
|
workspaceId: rootPage.workspaceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -393,14 +387,9 @@ export class PageService {
|
|||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
creatorId: authUser.id,
|
creatorId: authUser.id,
|
||||||
lastUpdatedById: authUser.id,
|
lastUpdatedById: authUser.id,
|
||||||
parentPageId:
|
parentPageId: page.id === rootPage.id
|
||||||
page.id === rootPage.id
|
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
|
||||||
? isDuplicateInSameSpace
|
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
|
||||||
? rootPage.parentPageId
|
|
||||||
: null
|
|
||||||
: page.parentPageId
|
|
||||||
? pageMap.get(page.parentPageId)?.newPageId
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -580,22 +569,22 @@ export class PageService {
|
|||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<PaginationResult<Page>> {
|
||||||
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPages(
|
async getRecentPages(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<PaginationResult<Page>> {
|
||||||
return this.pageRepo.getRecentPages(userId, pagination);
|
return await this.pageRepo.getRecentPages(userId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedSpacePages(
|
async getDeletedSpacePages(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<PaginationResult<Page>> {
|
||||||
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export class SearchService {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ items: SearchResponseDto[] }> {
|
): Promise<SearchResponseDto[]> {
|
||||||
const { query } = searchParams;
|
const { query } = searchParams;
|
||||||
|
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return { items: [] };
|
return;
|
||||||
}
|
}
|
||||||
const searchQuery = tsquery(query.trim() + '*');
|
const searchQuery = tsquery(query.trim() + '*');
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export class SearchService {
|
|||||||
)
|
)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.orderBy('rank', 'desc')
|
.orderBy('rank', 'desc')
|
||||||
.limit(searchParams.limit || 25)
|
.limit(searchParams.limit | 25)
|
||||||
.offset(searchParams.offset || 0);
|
.offset(searchParams.offset || 0);
|
||||||
|
|
||||||
if (!searchParams.shareId) {
|
if (!searchParams.shareId) {
|
||||||
@@ -86,7 +86,7 @@ export class SearchService {
|
|||||||
const shareId = searchParams.shareId;
|
const shareId = searchParams.shareId;
|
||||||
const share = await this.shareRepo.findById(shareId);
|
const share = await this.shareRepo.findById(shareId);
|
||||||
if (!share || share.workspaceId !== opts.workspaceId) {
|
if (!share || share.workspaceId !== opts.workspaceId) {
|
||||||
return { items: [] };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageIdsToSearch = [];
|
const pageIdsToSearch = [];
|
||||||
@@ -108,10 +108,10 @@ export class SearchService {
|
|||||||
.where('id', 'in', pageIdsToSearch)
|
.where('id', 'in', pageIdsToSearch)
|
||||||
.where('workspaceId', '=', opts.workspaceId);
|
.where('workspaceId', '=', opts.workspaceId);
|
||||||
} else {
|
} else {
|
||||||
return { items: [] };
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return { items: [] };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
@@ -127,7 +127,7 @@ export class SearchService {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items: searchResults };
|
return searchResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|||||||
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
||||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceMemberService {
|
export class SpaceMemberService {
|
||||||
@@ -68,16 +68,18 @@ export class SpaceMemberService {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<any>> {
|
) {
|
||||||
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
||||||
if (!space) {
|
if (!space) {
|
||||||
throw new NotFoundException('Space not found');
|
throw new NotFoundException('Space not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.spaceMemberRepo.getSpaceMembersPaginated(
|
const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
|
||||||
spaceId,
|
spaceId,
|
||||||
pagination,
|
pagination,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addMembersToSpaceBatch(
|
async addMembersToSpaceBatch(
|
||||||
@@ -274,7 +276,7 @@ export class SpaceMemberService {
|
|||||||
async getUserSpaces(
|
async getUserSpaces(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Space>> {
|
): Promise<PaginationResult<Space>> {
|
||||||
return this.spaceMemberRepo.getUserSpaces(userId, pagination);
|
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { Space, User } from '@docmost/db/types/entity.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 { UpdateSpaceDto } from '../dto/update-space.dto';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -16,7 +17,6 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
|
|||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpaceService {
|
export class SpaceService {
|
||||||
@@ -130,8 +130,13 @@ export class SpaceService {
|
|||||||
async getWorkspaceSpaces(
|
async getWorkspaceSpaces(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<Space>> {
|
): Promise<PaginationResult<Space>> {
|
||||||
return this.spaceRepo.getSpacesInWorkspace(workspaceId, pagination);
|
const spaces = await this.spaceRepo.getSpacesInWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return spaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-ac
|
|||||||
import { TokenService } from '../../auth/services/token.service';
|
import { TokenService } from '../../auth/services/token.service';
|
||||||
import { nanoIdGen } from '../../../common/helpers';
|
import { nanoIdGen } from '../../../common/helpers';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { DomainService } from 'src/integrations/environment/domain.service';
|
import { DomainService } from 'src/integrations/environment/domain.service';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
@@ -64,13 +64,12 @@ export class WorkspaceInvitationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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) {
|
async getInvitationById(invitationId: string, workspace: Workspace) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { User } from '@docmost/db/types/entity.types';
|
|||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
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 { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
@@ -27,12 +28,12 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
import { AttachmentType } from 'src/core/attachment/attachment.constants';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceService {
|
export class WorkspaceService {
|
||||||
@@ -375,8 +376,13 @@ export class WorkspaceService {
|
|||||||
async getWorkspaceUsers(
|
async getWorkspaceUsers(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
): Promise<CursorPaginationResult<User>> {
|
): Promise<PaginationResult<User>> {
|
||||||
return this.userRepo.getUsersPaginated(workspaceId, pagination);
|
const users = await this.userRepo.getUsersPaginated(
|
||||||
|
workspaceId,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWorkspaceUserRole(
|
async updateWorkspaceUserRole(
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
// 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<DB, TB extends keyof DB, O> =
|
|
||||||
| {
|
|
||||||
expression:
|
|
||||||
| (StringReference<DB, TB> & keyof O & string)
|
|
||||||
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
|
|
||||||
direction: OrderByDirection;
|
|
||||||
orderModifier?: OrderByModifiers;
|
|
||||||
key?: keyof O & string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
expression: ReferenceExpression<DB, TB>;
|
|
||||||
direction: OrderByDirection;
|
|
||||||
orderModifier?: OrderByModifiers;
|
|
||||||
key: keyof O & string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExtractSortFieldKey<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends SortField<DB, TB, O>,
|
|
||||||
> = 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<DB, TB extends keyof DB, O> = ReadonlyArray<
|
|
||||||
Readonly<SortField<DB, TB, O>>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type FieldNames<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
|
|
||||||
[TIndex in keyof T]: ExtractSortFieldKey<DB, TB, O, T[TIndex]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EncodeCursorValues<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
> = {
|
|
||||||
[TIndex in keyof T]: [
|
|
||||||
ExtractSortFieldKey<DB, TB, O, T[TIndex]>,
|
|
||||||
O[ExtractSortFieldKey<DB, TB, O, T[TIndex]>],
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CursorEncoder<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
> = (values: EncodeCursorValues<DB, TB, O, T>) => string;
|
|
||||||
|
|
||||||
type DecodedCursor<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
|
|
||||||
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CursorDecoder<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
> = (
|
|
||||||
cursor: string,
|
|
||||||
fields: FieldNames<DB, TB, O, T>,
|
|
||||||
) => DecodedCursor<DB, TB, O, T>;
|
|
||||||
|
|
||||||
type ParsedCursorValues<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
> = {
|
|
||||||
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: O[TField];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CursorParser<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
> = (cursor: DecodedCursor<DB, TB, O, T>) => ParsedCursorValues<DB, TB, O, T>;
|
|
||||||
|
|
||||||
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<TRow, TCursorKey>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function executeWithCursorPagination<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
const TFields extends Fields<DB, TB, O>,
|
|
||||||
TCursorKey extends string | boolean | undefined = undefined,
|
|
||||||
>(
|
|
||||||
qb: SelectQueryBuilder<DB, TB, O>,
|
|
||||||
opts: {
|
|
||||||
perPage: number;
|
|
||||||
cursor?: string;
|
|
||||||
beforeCursor?: string;
|
|
||||||
cursorPerRow?: TCursorKey;
|
|
||||||
fields: TFields;
|
|
||||||
encodeCursor?: CursorEncoder<DB, TB, O, TFields>;
|
|
||||||
decodeCursor?: CursorDecoder<DB, TB, O, TFields>;
|
|
||||||
parseCursor:
|
|
||||||
| CursorParser<DB, TB, O, TFields>
|
|
||||||
| { parse: CursorParser<DB, TB, O, TFields> };
|
|
||||||
},
|
|
||||||
): Promise<CursorPaginationResult<O, TCursorKey>> {
|
|
||||||
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<DB, TB, O, TFields>;
|
|
||||||
|
|
||||||
return encodeCursor(cursorFieldValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldNames = fields.map((field) => field.key) as FieldNames<
|
|
||||||
DB,
|
|
||||||
TB,
|
|
||||||
O,
|
|
||||||
TFields
|
|
||||||
>;
|
|
||||||
|
|
||||||
function applyCursor(
|
|
||||||
qb: SelectQueryBuilder<DB, TB, O>,
|
|
||||||
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<O, TCursorKey>;
|
|
||||||
}),
|
|
||||||
meta: {
|
|
||||||
limit: opts.perPage,
|
|
||||||
hasNextPage,
|
|
||||||
hasPrevPage,
|
|
||||||
nextCursor,
|
|
||||||
prevCursor,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultEncodeCursor<
|
|
||||||
DB,
|
|
||||||
TB extends keyof DB,
|
|
||||||
O,
|
|
||||||
T extends Fields<DB, TB, O>,
|
|
||||||
>(values: EncodeCursorValues<DB, TB, O, T>) {
|
|
||||||
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<DB, TB, O>,
|
|
||||||
>(
|
|
||||||
cursor: string,
|
|
||||||
fields: FieldNames<DB, TB, O, T>,
|
|
||||||
): DecodedCursor<DB, TB, O, T> {
|
|
||||||
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<DB, TB, O, T>;
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class PaginationOptions {
|
export class PaginationOptions {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
@@ -16,14 +21,6 @@ export class PaginationOptions {
|
|||||||
@Max(100)
|
@Max(100)
|
||||||
limit = 20;
|
limit = 20;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
cursor?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
beforeCursor?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatableComment,
|
UpdatableComment,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
@@ -37,15 +37,15 @@ export class CommentRepo {
|
|||||||
.selectAll('comments')
|
.selectAll('comments')
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.select((eb) => this.withResolvedBy(eb))
|
.select((eb) => this.withResolvedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId)
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
|
||||||
beforeCursor: pagination.beforeCursor,
|
|
||||||
fields: [{ expression: 'id', direction: 'asc' }],
|
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateComment(
|
async updateComment(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { dbOrTx, executeTx } from '@docmost/db/utils';
|
|||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
|
||||||
@@ -52,7 +52,8 @@ export class GroupUserRepo {
|
|||||||
.selectFrom('groupUsers')
|
.selectFrom('groupUsers')
|
||||||
.innerJoin('users', 'users.id', 'groupUsers.userId')
|
.innerJoin('users', 'users.id', 'groupUsers.userId')
|
||||||
.selectAll('users')
|
.selectAll('users')
|
||||||
.where('groupId', '=', groupId);
|
.where('groupId', '=', groupId)
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -60,12 +61,9 @@ export class GroupUserRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWithCursorPagination(query, {
|
const result = await executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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) => {
|
result.items.map((user) => {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GroupRepo {
|
export class GroupRepo {
|
||||||
@@ -104,19 +104,17 @@ export class GroupRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||||
let baseQuery = this.db
|
let query = this.db
|
||||||
.selectFrom('groups')
|
.selectFrom('groups')
|
||||||
.selectAll('groups')
|
.selectAll('groups')
|
||||||
.select((eb) => this.withMemberCount(eb))
|
.select((eb) => this.withMemberCount(eb))
|
||||||
.where('workspaceId', '=', workspaceId);
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('memberCount', 'desc')
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
baseQuery = baseQuery.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
||||||
sql`f_unaccent(name)`,
|
|
||||||
'ilike',
|
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
|
||||||
).or(
|
|
||||||
sql`f_unaccent(description)`,
|
sql`f_unaccent(description)`,
|
||||||
'ilike',
|
'ilike',
|
||||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||||
@@ -124,24 +122,12 @@ export class GroupRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
|
const result = executeWithPagination(query, {
|
||||||
return executeWithCursorPagination(query, {
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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<DB, 'groups'>) {
|
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PageHistory,
|
PageHistory,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -65,15 +65,15 @@ export class PageHistoryRepo {
|
|||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId)
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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) {
|
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatablePage,
|
UpdatablePage,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -281,21 +281,15 @@ export class PageRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('deletedAt', 'is', null);
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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) {
|
async getRecentPages(userId: string, pagination: PaginationOptions) {
|
||||||
@@ -304,20 +298,12 @@ export class PageRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
||||||
.where('deletedAt', 'is', null);
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
return executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,21 +331,15 @@ export class PageRepo {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
);
|
)
|
||||||
|
.orderBy('deletedAt', 'desc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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<DB, 'pages'>) {
|
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
@@ -442,8 +422,6 @@ export class PageRepo {
|
|||||||
'parentPageId',
|
'parentPageId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.where('id', '=', parentPageId)
|
.where('id', '=', parentPageId)
|
||||||
@@ -460,8 +438,6 @@ export class PageRepo {
|
|||||||
'p.parentPageId',
|
'p.parentPageId',
|
||||||
'p.spaceId',
|
'p.spaceId',
|
||||||
'p.workspaceId',
|
'p.workspaceId',
|
||||||
'p.createdAt',
|
|
||||||
'p.updatedAt',
|
|
||||||
])
|
])
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
|
||||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UpdatableShare,
|
UpdatableShare,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
@@ -143,20 +143,12 @@ export class ShareRepo {
|
|||||||
.select((eb) => this.withPage(eb))
|
.select((eb) => this.withPage(eb))
|
||||||
.select((eb) => this.withSpace(eb, userId))
|
.select((eb) => this.withSpace(eb, userId))
|
||||||
.select((eb) => this.withCreator(eb))
|
.select((eb) => this.withCreator(eb))
|
||||||
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId));
|
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
|
||||||
|
.orderBy('updatedAt', 'desc');
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
return executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { MemberInfo, UserSpaceRole } from './types';
|
import { MemberInfo, UserSpaceRole } from './types';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ export class SpaceMemberRepo {
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
) {
|
) {
|
||||||
let baseQuery = this.db
|
let query = this.db
|
||||||
.selectFrom('spaceMembers')
|
.selectFrom('spaceMembers')
|
||||||
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
.leftJoin('users', 'users.id', 'spaceMembers.userId')
|
||||||
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
|
||||||
@@ -114,11 +114,12 @@ export class SpaceMemberRepo {
|
|||||||
'spaceMembers.createdAt',
|
'spaceMembers.createdAt',
|
||||||
])
|
])
|
||||||
.select((eb) => this.groupRepo.withMemberCount(eb))
|
.select((eb) => this.groupRepo.withMemberCount(eb))
|
||||||
.select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
|
.where('spaceId', '=', spaceId)
|
||||||
.where('spaceId', '=', spaceId);
|
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
|
||||||
|
.orderBy('spaceMembers.createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
baseQuery = baseQuery.where((eb) =>
|
query = query.where((eb) =>
|
||||||
eb(
|
eb(
|
||||||
sql`f_unaccent(users.name)`,
|
sql`f_unaccent(users.name)`,
|
||||||
'ilike',
|
'ilike',
|
||||||
@@ -137,20 +138,9 @@ export class SpaceMemberRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
|
const result = await executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
const result = await executeWithCursorPagination(query, {
|
|
||||||
perPage: pagination.limit,
|
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;
|
let memberInfo: MemberInfo;
|
||||||
@@ -245,7 +235,8 @@ export class SpaceMemberRepo {
|
|||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
|
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
|
||||||
.where('id', 'in', this.getUserSpaceIdsQuery(userId));
|
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -261,12 +252,9 @@ export class SpaceMemberRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
return executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
|
||||||
beforeCursor: pagination.beforeCursor,
|
|
||||||
fields: [{ expression: 'id', direction: 'asc' }],
|
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
@@ -110,7 +110,8 @@ export class SpaceRepo {
|
|||||||
.selectFrom('spaces')
|
.selectFrom('spaces')
|
||||||
.selectAll('spaces')
|
.selectAll('spaces')
|
||||||
.select((eb) => [this.withMemberCount(eb)])
|
.select((eb) => [this.withMemberCount(eb)])
|
||||||
.where('workspaceId', '=', workspaceId);
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -126,13 +127,12 @@ export class SpaceRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
|
||||||
beforeCursor: pagination.beforeCursor,
|
|
||||||
fields: [{ expression: 'id', direction: 'asc' }],
|
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
|
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
|
||||||
@@ -145,7 +145,8 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
.where('deletedAt', 'is', null);
|
.where('deletedAt', 'is', null)
|
||||||
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
query = query.where((eb) =>
|
query = query.where((eb) =>
|
||||||
@@ -161,13 +162,12 @@ export class UserRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
cursor: pagination.cursor,
|
|
||||||
beforeCursor: pagination.beforeCursor,
|
|
||||||
fields: [{ expression: 'id', direction: 'asc' }],
|
|
||||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePreference(
|
async updatePreference(
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...b6844b019c
@@ -55,7 +55,7 @@ export class ExportController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipFileStream = await this.exportService.exportPages(
|
const zipFileBuffer = await this.exportService.exportPages(
|
||||||
dto.pageId,
|
dto.pageId,
|
||||||
dto.format,
|
dto.format,
|
||||||
dto.includeAttachments,
|
dto.includeAttachments,
|
||||||
@@ -70,7 +70,7 @@ export class ExportController {
|
|||||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||||
});
|
});
|
||||||
|
|
||||||
res.send(zipFileStream);
|
res.send(zipFileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -100,6 +100,6 @@ export class ExportController {
|
|||||||
'"',
|
'"',
|
||||||
});
|
});
|
||||||
|
|
||||||
res.send(exportFile.fileStream);
|
res.send(exportFile.fileBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util';
|
||||||
|
import { turndown } from './turndown-utils';
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
@@ -20,23 +21,16 @@ import {
|
|||||||
replaceInternalLinks,
|
replaceInternalLinks,
|
||||||
updateAttachmentUrlsToLocalPaths,
|
updateAttachmentUrlsToLocalPaths,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import {
|
|
||||||
ExportMetadata,
|
|
||||||
ExportPageMetadata,
|
|
||||||
} from '../../common/helpers/types/export-metadata.types';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { EditorState } from '@tiptap/pm/state';
|
import { EditorState } from '@tiptap/pm/state';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import slugify = require('@sindresorhus/slugify');
|
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 { EnvironmentService } from '../environment/environment.service';
|
||||||
import {
|
import {
|
||||||
getAttachmentIds,
|
getAttachmentIds,
|
||||||
getProsemirrorContent,
|
getProsemirrorContent,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -89,7 +83,7 @@ export class ExportService {
|
|||||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
return htmlToMarkdown(newPageHtml);
|
return turndown(newPageHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -161,17 +155,12 @@ export class ExportService {
|
|||||||
'pages.id',
|
'pages.id',
|
||||||
'pages.slugId',
|
'pages.slugId',
|
||||||
'pages.title',
|
'pages.title',
|
||||||
'pages.icon',
|
|
||||||
'pages.position',
|
|
||||||
'pages.content',
|
'pages.content',
|
||||||
'pages.parentPageId',
|
'pages.parentPageId',
|
||||||
'pages.spaceId',
|
'pages.spaceId',
|
||||||
'pages.workspaceId',
|
'pages.workspaceId',
|
||||||
'pages.createdAt',
|
|
||||||
'pages.updatedAt',
|
|
||||||
])
|
])
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const tree = buildTree(pages as Page[]);
|
const tree = buildTree(pages as Page[]);
|
||||||
@@ -188,7 +177,7 @@ export class ExportService {
|
|||||||
|
|
||||||
const fileName = `${space.name}-space-export.zip`;
|
const fileName = `${space.name}-space-export.zip`;
|
||||||
return {
|
return {
|
||||||
fileStream: zipFile,
|
fileBuffer: zipFile,
|
||||||
fileName,
|
fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -200,12 +189,10 @@ export class ExportService {
|
|||||||
includeAttachments: boolean,
|
includeAttachments: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const slugIdToPath: Record<string, string> = {};
|
const slugIdToPath: Record<string, string> = {};
|
||||||
const pageIdToFilePath: Record<string, string> = {};
|
|
||||||
const pagesMetadata: Record<string, ExportPageMetadata> = {};
|
|
||||||
|
|
||||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||||
|
|
||||||
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
const stack: { folder: JSZip; parentPageId: string }[] = [
|
||||||
{ folder: zip, parentPageId: null },
|
{ folder: zip, parentPageId: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -245,35 +232,12 @@ export class ExportService {
|
|||||||
`${pageTitle}${getExportExtension(format)}`,
|
`${pageTitle}${getExportExtension(format)}`,
|
||||||
pageExportContent,
|
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) {
|
if (childPages.length > 0) {
|
||||||
const pageFolder = folder.folder(pageTitle);
|
const pageFolder = folder.folder(pageTitle);
|
||||||
stack.push({ folder: pageFolder, parentPageId: page.id });
|
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) {
|
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||||
|
|||||||
+28
-31
@@ -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 TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||||
import { getBasename } from './basename';
|
import * as path from 'path';
|
||||||
|
|
||||||
// CJS/ESM interop: .default exists in Vite, not in NestJS
|
export function turndown(html: string): string {
|
||||||
const TurndownService = (_TurndownService as any).default || _TurndownService;
|
|
||||||
|
|
||||||
export function htmlToMarkdown(html: string): string {
|
|
||||||
const turndownService = new TurndownService({
|
const turndownService = new TurndownService({
|
||||||
headingStyle: 'atx',
|
headingStyle: 'atx',
|
||||||
codeBlockStyle: 'fenced',
|
codeBlockStyle: 'fenced',
|
||||||
hr: '---',
|
hr: '---',
|
||||||
bulletListMarker: '-',
|
bulletListMarker: '-',
|
||||||
});
|
});
|
||||||
|
const tables = TurndownPluginGfm.tables;
|
||||||
|
const strikethrough = TurndownPluginGfm.strikethrough;
|
||||||
|
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
||||||
|
|
||||||
turndownService.use([
|
turndownService.use([
|
||||||
TurndownPluginGfm.tables,
|
tables,
|
||||||
TurndownPluginGfm.strikethrough,
|
strikethrough,
|
||||||
TurndownPluginGfm.highlightedCodeBlock,
|
highlightedCodeBlock,
|
||||||
taskList,
|
taskList,
|
||||||
callout,
|
callout,
|
||||||
preserveDetail,
|
preserveDetail,
|
||||||
@@ -29,33 +29,34 @@ export function htmlToMarkdown(html: string): string {
|
|||||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function listParagraph(turndownService: _TurndownService) {
|
function listParagraph(turndownService: TurndownService) {
|
||||||
turndownService.addRule('paragraph', {
|
turndownService.addRule('paragraph', {
|
||||||
filter: ['p'],
|
filter: ['p'],
|
||||||
replacement: (content: string, node: HTMLInputElement) => {
|
replacement: (content: any, node: HTMLInputElement) => {
|
||||||
if (node.parentElement?.nodeName === 'LI') {
|
if (node.parentElement?.nodeName === 'LI') {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `\n\n${content}\n\n`;
|
return `\n\n${content}\n\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function callout(turndownService: _TurndownService) {
|
function callout(turndownService: TurndownService) {
|
||||||
turndownService.addRule('callout', {
|
turndownService.addRule('callout', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: string, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
const calloutType = node.getAttribute('data-callout-type');
|
const calloutType = node.getAttribute('data-callout-type');
|
||||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskList(turndownService: _TurndownService) {
|
function taskList(turndownService: TurndownService) {
|
||||||
turndownService.addRule('taskListItem', {
|
turndownService.addRule('taskListItem', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +64,7 @@ function taskList(turndownService: _TurndownService) {
|
|||||||
node.parentNode.nodeName === 'UL'
|
node.parentNode.nodeName === 'UL'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: string, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
const checkbox = node.querySelector(
|
const checkbox = node.querySelector(
|
||||||
'input[type="checkbox"]',
|
'input[type="checkbox"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -78,21 +79,17 @@ function taskList(turndownService: _TurndownService) {
|
|||||||
// Create the checkbox prefix
|
// Create the checkbox prefix
|
||||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
||||||
|
|
||||||
return (
|
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '');
|
||||||
prefix +
|
|
||||||
content +
|
|
||||||
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function preserveDetail(turndownService: _TurndownService) {
|
function preserveDetail(turndownService: TurndownService) {
|
||||||
turndownService.addRule('preserveDetail', {
|
turndownService.addRule('preserveDetail', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.nodeName === 'DETAILS';
|
return node.nodeName === 'DETAILS';
|
||||||
},
|
},
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
const summary = node.querySelector(':scope > summary');
|
const summary = node.querySelector(':scope > summary');
|
||||||
let detailSummary = '';
|
let detailSummary = '';
|
||||||
|
|
||||||
@@ -114,7 +111,7 @@ function preserveDetail(turndownService: _TurndownService) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mathInline(turndownService: _TurndownService) {
|
function mathInline(turndownService: TurndownService) {
|
||||||
turndownService.addRule('mathInline', {
|
turndownService.addRule('mathInline', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -122,13 +119,13 @@ function mathInline(turndownService: _TurndownService) {
|
|||||||
node.getAttribute('data-type') === 'mathInline'
|
node.getAttribute('data-type') === 'mathInline'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: string) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
return `$${content}$`;
|
return `$${content}$`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mathBlock(turndownService: _TurndownService) {
|
function mathBlock(turndownService: TurndownService) {
|
||||||
turndownService.addRule('mathBlock', {
|
turndownService.addRule('mathBlock', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return (
|
return (
|
||||||
@@ -136,32 +133,32 @@ function mathBlock(turndownService: _TurndownService) {
|
|||||||
node.getAttribute('data-type') === 'mathBlock'
|
node.getAttribute('data-type') === 'mathBlock'
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
replacement: function (content: string) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
return `\n$$\n${content}\n$$\n`;
|
return `\n$$\n${content}\n$$\n`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function iframeEmbed(turndownService: _TurndownService) {
|
function iframeEmbed(turndownService: TurndownService) {
|
||||||
turndownService.addRule('iframeEmbed', {
|
turndownService.addRule('iframeEmbed', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.nodeName === 'IFRAME';
|
return node.nodeName === 'IFRAME';
|
||||||
},
|
},
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
const src = node.getAttribute('src');
|
const src = node.getAttribute('src');
|
||||||
return '[' + src + '](' + src + ')';
|
return '[' + src + '](' + src + ')';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function video(turndownService: _TurndownService) {
|
function video(turndownService: TurndownService) {
|
||||||
turndownService.addRule('video', {
|
turndownService.addRule('video', {
|
||||||
filter: function (node: HTMLInputElement) {
|
filter: function (node: HTMLInputElement) {
|
||||||
return node.tagName === 'VIDEO';
|
return node.tagName === 'VIDEO';
|
||||||
},
|
},
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
replacement: function (content: any, node: HTMLInputElement) {
|
||||||
const src = node.getAttribute('src') || '';
|
const src = node.getAttribute('src') || '';
|
||||||
const name = getBasename(src) || src;
|
const name = path.basename(src);
|
||||||
return '[' + name + '](' + src + ')';
|
return '[' + name + '](' + src + ')';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
import { jsonToNode } from 'src/collaboration/collaboration.util';
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { ExportFormat } from './dto/export-dto';
|
import { ExportFormat } from './dto/export-dto';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
@@ -89,7 +88,7 @@ export function replaceInternalLinks(
|
|||||||
// if link and text are same, use page title
|
// if link and text are same, use page title
|
||||||
if (markLink === node.text) {
|
if (markLink === node.text) {
|
||||||
//@ts-expect-error
|
//@ts-expect-error
|
||||||
node.text = getInternalLinkPageName(relativePath, currentPagePath);
|
node.text = getInternalLinkPageName(relativePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,19 +99,10 @@ export function replaceInternalLinks(
|
|||||||
return doc.toJSON();
|
return doc.toJSON();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
|
export function getInternalLinkPageName(path: string): string {
|
||||||
const name = path?.split('/').pop().split('.').slice(0, -1).join('.');
|
return decodeURIComponent(
|
||||||
try {
|
path?.split('/').pop().split('.').slice(0, -1).join('.'),
|
||||||
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 {
|
export function extractPageSlugId(input: string): string {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user