Compare commits

..

16 Commits

Author SHA1 Message Date
Philipinho 207c119792 fix collab server logging too 2026-01-29 00:41:00 +00:00
Philipinho 4683a85a8b - fix: set default Nest logger and bufferLogs to false for pino compatibility
- handle redis error event
2026-01-29 00:36:48 +00:00
Philip Okugbe 74e915546b feat: collab redis extension with server affinity (#1873)
* feat(collab): better redis extension
* move types to own file
* debug logging
* fix: graceful collab shutdown
* rename default prefix
* pass wsAdapter to gateway
* expose event handler
* unique collab serverId generation
* uninstall @hocuspocus/extension-redis package
* expose more functions
* sync with latest
* cleanup
* fastify router options
* cleanup type
2026-01-27 17:05:05 +00:00
Philipinho 3523600f40 add timestamps 2026-01-27 16:49:22 +00:00
Philip Okugbe 6ccb2bb872 feat(export): add metadata file to preserve page icons and ordering on import (#1877)
* feat(export): add metadata file to preserve page icons and ordering on import
- Export includes `docmost-metadata.json`
- Import reads metadata to restore icons and sort siblings by original position

* cleanup

* bonus fixes

* handle unknown prosemirror nodes

* add docmost app  version
2026-01-27 16:39:39 +00:00
Philipinho 0245a183e1 sync 2026-01-26 02:08:54 +00:00
Philip Okugbe de5f71894a New Crowdin updates (#1869)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-01-25 12:39:19 +00:00
Philip Okugbe 351b075ebb fix(tree): update sidebar-pages cache directly instead of refetching on page move (#1870) 2026-01-25 12:38:44 +00:00
Philipinho 1ca7d42203 fix switch space toggle 2026-01-25 02:49:25 +00:00
Philipinho 1e441560f6 fix production logs filter 2026-01-25 02:15:10 +00:00
Philip Okugbe 54775f537d fix: handle malformed URLs gracefully during import/export (#1868)
* Handling malformed URLs gracefully

* Allow import of invalid URLs, but adding logging.

---------

Co-authored-by: gpapp <gergely.papp@itworks.hu>
2026-01-25 00:48:43 +00:00
Philipinho 5dbf0027bd Add isomorphic basename utility 2026-01-25 00:08:02 +00:00
Philip Okugbe 5588ec34fb New Crowdin updates (#1866)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2026-01-25 00:04:50 +00:00
Philipinho 55b8128829 Fix Google sheets regex 2026-01-24 23:35:04 +00:00
Philip Okugbe aa6a046aa6 feat(export): add export loading state and copy as markdown (#1867)
* feat: add loading state to export

* feat: copy as markdown

* preserve taskList comment
2026-01-24 23:30:17 +00:00
Philip Okugbe 657fdf8cb7 feat: Tiptap V3 migration (#1854)
* Tiptap3 migration - WIP

* fix collaboration

* remove unused code

* fix flicker

* disable duplicate extensions

* update tiptap version

* Switch to useEditorState
- Set shouldRerenderOnTransaction to false

* fix editable state

* add tippyoptions for reference

* merge main

* tiptap 3.6.1

* fix bubble menu

* fix converter

* fix menus

* fix collaboration caret css

* fix: Set `isInitialized` to force immediate react node view rendering

* feat: Migrate tippy.js menus to Floating UI

* feat: Update collaboration connection for HocusPocus v3

* fix: Connect/disconnect websocketProvider

* cleanup

* cleanup

* feat: Improved placeholder and upload handling for images

* feat: Improved placeholder and upload handling for videos

* refactor: Image node and view clean-up

* feat: Improved placeholder and upload handling for attachments

* fix: Video view styles

* fix: Transaction handling on asset upload

* fix: Use imageDimensionsFromStream

* feat: Multiple file upload, improved placeholders, local previews

* fix: Drag & drop, paste upload

* fix: Allow media as attachment

* * add skeleton pulse animation
* add translation strings
* fix attachment view responsiveness

* fix collab connection status display

* Tiptap v3.17.0

* fix suggestion menu exit bug

* fix search shortcut

* fix history editor css

* tiptap 3.17.1

---------

Co-authored-by: Arek Nawo <areknawo@areknawo.com>
2026-01-24 20:41:08 +00:00
59 changed files with 1445 additions and 183 deletions
@@ -29,6 +29,7 @@
"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",
@@ -234,7 +235,9 @@
"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.",
@@ -251,6 +254,7 @@
"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}}",
@@ -326,6 +330,8 @@
"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,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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",
@@ -328,6 +330,8 @@
"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,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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",
@@ -328,6 +330,8 @@
"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,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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}}",
@@ -328,6 +330,8 @@
"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,6 +29,7 @@
"Choose your preferred interface language.": "お好みの言語を選択してください", "Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください", "Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認", "Confirm": "確認",
"Copy as Markdown": "Markdownとしてコピー",
"Copy link": "リンクをコピー", "Copy link": "リンクをコピー",
"Create": "新規作成", "Create": "新規作成",
"Create group": "グループを作成", "Create group": "グループを作成",
@@ -253,6 +254,7 @@
"Export failed:": "エクスポートに失敗しました:", "Export failed:": "エクスポートに失敗しました:",
"export error": "エクスポートエラー", "export error": "エクスポートエラー",
"Export page": "エクスポートページ", "Export page": "エクスポートページ",
"Export successful": "エクスポート成功",
"Export space": "エクスポートスペース", "Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート", "Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします", "Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
"Table": "テーブル", "Table": "テーブル",
"Insert a table.": "テーブルを挿入します", "Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します", "Insert collapsible block.": "折りたたみブロックを挿入します",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.", "Choose your preferred interface language.": "선호하는 인터페이스 언어를 선택하세요.",
"Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.", "Choose your preferred page width.": "선호하는 페이지 너비를 선택하세요.",
"Confirm": "확인", "Confirm": "확인",
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사", "Copy link": "링크 복사",
"Create": "생성", "Create": "생성",
"Create group": "팀 생성", "Create group": "팀 생성",
@@ -253,6 +254,7 @@
"Export failed:": "내보내기 실패:", "Export failed:": "내보내기 실패:",
"export error": "내보내기 오류", "export error": "내보내기 오류",
"Export page": "페이지 내보내기", "Export page": "페이지 내보내기",
"Export successful": "내보내기 성공",
"Export 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}}을 초과했습니다",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.", "Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
"Table": "테이블", "Table": "테이블",
"Insert a table.": "테이블 삽입.", "Insert a table.": "테이블 삽입.",
"Insert collapsible block.": "접을 수 있는 블록 삽입.", "Insert collapsible block.": "접을 수 있는 블록 삽입.",
@@ -29,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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}}",
@@ -328,6 +330,8 @@
"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,6 +29,7 @@
"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",
@@ -253,6 +254,7 @@
"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}}",
@@ -328,6 +330,8 @@
"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,6 +29,7 @@
"Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.", "Choose your preferred interface language.": "Выберите предпочитаемый язык интерфейса.",
"Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.", "Choose your preferred page width.": "Выберите предпочитаемую ширину страницы.",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Copy as Markdown": "Копировать как Markdown",
"Copy link": "Копировать ссылку", "Copy link": "Копировать ссылку",
"Create": "Создать", "Create": "Создать",
"Create group": "Создать группу", "Create group": "Создать группу",
@@ -253,6 +254,7 @@
"Export failed:": "Экспортирование не удалось:", "Export failed:": "Экспортирование не удалось:",
"export error": "ошибка экспорта", "export error": "ошибка экспорта",
"Export page": "Экспорт страницы", "Export page": "Экспорт страницы",
"Export successful": "Экспорт выполнен успешно",
"Export space": "Экспорт пространства", "Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}", "Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.", "Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
"Table": "Таблица", "Table": "Таблица",
"Insert a table.": "Вставить таблицу.", "Insert a table.": "Вставить таблицу.",
"Insert collapsible block.": "Вставить сворачиваемый блок.", "Insert collapsible block.": "Вставить сворачиваемый блок.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.", "Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.", "Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити", "Confirm": "Підтвердити",
"Copy as Markdown": "Скопіювати як Markdown",
"Copy link": "Копіювати посилання", "Copy link": "Копіювати посилання",
"Create": "Створити", "Create": "Створити",
"Create group": "Створити групу", "Create group": "Створити групу",
@@ -253,6 +254,7 @@
"Export failed:": "Експортування не вдалося:", "Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту", "export error": "помилка експорту",
"Export page": "Експорт сторінки", "Export page": "Експорт сторінки",
"Export successful": "Експорт виконано успішно",
"Export space": "Експорт простору", "Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}", "Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.", "Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
"Table": "Таблиця", "Table": "Таблиця",
"Insert a table.": "Вставити таблицю.", "Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.", "Insert collapsible block.": "Вставити блок, що згортається.",
@@ -29,6 +29,7 @@
"Choose your preferred interface language.": "选择您喜欢的界面语言。", "Choose your preferred interface language.": "选择您喜欢的界面语言。",
"Choose your preferred page width.": "选择您喜欢的页面宽度。", "Choose your preferred page width.": "选择您喜欢的页面宽度。",
"Confirm": "确认", "Confirm": "确认",
"Copy as Markdown": "复制为Markdown",
"Copy link": "复制链接", "Copy link": "复制链接",
"Create": "创建", "Create": "创建",
"Create group": "创建群组", "Create group": "创建群组",
@@ -253,6 +254,7 @@
"Export failed:": "导出失败:", "Export failed:": "导出失败:",
"export error": "导出出错", "export error": "导出出错",
"Export page": "导出页面", "Export page": "导出页面",
"Export successful": "导出成功",
"Export space": "导出空间", "Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}", "Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
@@ -328,6 +330,8 @@
"Upload any image from your device.": "从设备上传任何图像", "Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频", "Upload any video from your device.": "从设备上传任何视频",
"Upload any file from your device.": "从设备上传任何文件", "Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
"Table": "表格", "Table": "表格",
"Insert a table.": "插入一个表格", "Insert a table.": "插入一个表格",
"Insert collapsible block.": "插入一个折叠块", "Insert collapsible block.": "插入一个折叠块",
@@ -30,9 +30,11 @@ 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({
@@ -45,6 +47,9 @@ 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({
@@ -52,6 +57,8 @@ export default function ExportModal({
color: "red", color: "red",
}); });
console.error("export error", err); console.error("export error", err);
} finally {
setIsExporting(false);
} }
}; };
@@ -136,7 +143,7 @@ export default function ExportModal({
<Button onClick={onClose} variant="default"> <Button onClick={onClose} variant="default">
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button onClick={handleExport}>{t("Export")}</Button> <Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
</Group> </Group>
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
@@ -7,6 +7,7 @@ import {
IconHistory, IconHistory,
IconLink, IconLink,
IconList, IconList,
IconMarkdown,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconTrash, IconTrash,
@@ -28,6 +29,7 @@ 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,
@@ -129,6 +131,15 @@ 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();
@@ -166,6 +177,13 @@ 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} />}>
@@ -172,6 +172,10 @@ 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,9 +163,6 @@ 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();
},
}); });
} }
@@ -458,17 +455,127 @@ export function invalidateOnUpdatePage(
}); });
} }
export function invalidateOnMovePage() { export function updateCacheOnMovePage(
//for move invalidate all sidebars for now (how to do???) spaceId: string,
//invalidate all root sidebar pages pageId: string,
queryClient.invalidateQueries({ oldParentId: string | null,
queryKey: ["root-sidebar-pages"], newParentId: string | null,
}); pageData: Partial<IPage>,
//invalidate all sub sidebar pages ) {
queryClient.invalidateQueries({ // Remove page from old parent's cache
queryKey: ["sidebar-pages"], const oldQueryKey =
}); 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) {
@@ -118,7 +118,14 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName)); let decodedFileName = 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) {
@@ -16,6 +16,7 @@ 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";
@@ -175,9 +176,25 @@ 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",
@@ -185,8 +202,10 @@ 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);
@@ -6,6 +6,7 @@ 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;
@@ -14,6 +15,7 @@ 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();
@@ -35,12 +37,15 @@ 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);
} }
}; };
@@ -79,7 +84,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"> <Button onClick={handleDelete} color="red" loading={isDeleting}>
{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 } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } 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, open, toggle }] = useDisclosure(false); const [opened, { close, 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={<IconChevronDown size={18} />} rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
color="gray" color="gray"
onClick={open} onClick={toggle}
> >
<CustomAvatar <CustomAvatar
name={spaceName} name={spaceName}
@@ -69,5 +69,12 @@ export async function exportSpace(data: IExportSpaceParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName)); let decodedFileName = fileName;
try {
decodedFileName = decodeURIComponent(fileName);
} catch (err) {
// fallback to raw filename
}
saveAs(req.data, decodedFileName);
} }
@@ -45,8 +45,10 @@ 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,
invalidateOnMovePage, updateCacheOnMovePage,
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,7 +41,13 @@ export const useQuerySubscription = () => {
invalidateOnCreatePage(data.payload.data); invalidateOnCreatePage(data.payload.data);
break; break;
case "moveTreeNode": case "moveTreeNode":
invalidateOnMovePage(); updateCacheOnMovePage(
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);
+3
View File
@@ -76,8 +76,10 @@
"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",
@@ -102,6 +104,7 @@
"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,14 +30,22 @@ export class CollabWsAdapter {
return this.wss; return this.wss;
} }
public destroy() { public close() {
try { try {
this.wss.clients.forEach((client) => {
client.terminate();
});
this.wss.close(); this.wss.close();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
public destroy() {
try {
this.wss.close();
this.wss.clients.forEach((client) => {
client.terminate();
});
} catch (err) {
console.error(err);
}
}
} }
@@ -1,10 +1,9 @@
import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server'; import { Hocuspocus } 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,
@@ -12,19 +11,39 @@ 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 hocuspocus: Hocuspocus; private readonly 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,
@@ -34,26 +53,80 @@ 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 {
this.hocuspocus.handleConnection(client, request); if (this.redisSync) {
const serializedHTTPRequest = this.serializeRequest(request);
const socketId = serializedHTTPRequest.headers['sec-websocket-key'];
// Create wrapper socket that only receives events via emit()
// This prevents double-handling since Hocuspocus won't listen to raw WebSocket events
const wrappedSocket = new WsSocketWrapper(client);
// Route through RedisSync extension (this calls handleConnection internally)
this.redisSync.onSocketOpen(wrappedSocket as any, serializedHTTPRequest);
// Forward raw WebSocket messages to the extension
client.on('message', (data: ArrayBuffer) => {
this.redisSync!.onSocketMessage(
wrappedSocket as any,
serializedHTTPRequest,
data,
);
});
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason);
});
// Forward pong events for keepalive
client.on('pong', (data: Buffer) => {
wrappedSocket.emit('pong', data);
});
} else {
// Fallback to direct Hocuspocus connection
this.hocuspocus.handleConnection(client, request);
}
} }
getConnectionCount() { getConnectionCount() {
@@ -64,7 +137,52 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount(); return this.hocuspocus.getDocumentsCount();
} }
async destroy(): Promise<void> { handleYjsEvent<TName extends keyof CollabEventHandlers>(
//await this.hocuspocus.destroy(); eventName: TName,
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 });
} }
} }
@@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);
constructor() {}
getHandlers(hocuspocus: Hocuspocus) {
return {
alterState: async (documentName: string, payload: { pageId: string }) => {
// dummy
// this.logger.log('Processing', documentName, payload);
// await this.withYdocConnection(hocuspocus, documentName, {}, (doc) => {
// const fragment = doc.getXmlFragment('default');
//});
},
};
}
async withYdocConnection(
hocuspocus: Hocuspocus,
documentName: string,
context: any = {},
fn: (doc: Document) => void,
): Promise<void> {
const connection = await hocuspocus.openDirectConnection(
documentName,
context,
);
try {
await connection.transact(fn);
} finally {
await connection.disconnect();
}
}
}
@@ -9,6 +9,7 @@ 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: [
@@ -17,6 +18,7 @@ import { LoggerExtension } from './extensions/logger.extension';
PersistenceExtension, PersistenceExtension,
LoggerExtension, LoggerExtension,
HistoryListener, HistoryListener,
CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
imports: [TokenModule], imports: [TokenModule],
@@ -46,16 +48,12 @@ export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
}); });
wss.on('error', (error) => wss.on('error', (error) =>
this.logger.log('WebSocket server error:', error), this.logger.error('WebSocket server error:', error),
); );
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.collaborationGateway) { await this.collaborationGateway?.destroy(this.collabWsAdapter);
await this.collaborationGateway.destroy(); this.collabWsAdapter?.destroy();
}
if (this.collabWsAdapter) {
this.collabWsAdapter.destroy();
}
} }
} }
@@ -41,7 +41,8 @@ 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 } from '@tiptap/pm/model'; import { Node, Schema } from '@tiptap/pm/model';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -110,9 +111,53 @@ export function jsonToText(tiptapJson: JSONContent) {
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
return Node.fromJSON(getSchema(tiptapExtensions), tiptapJson); const schema = getSchema(tiptapExtensions);
try {
return Node.fromJSON(schema, tiptapJson);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes('Unknown node type')
) {
Logger.warn('Stripping unknown node types from document:', error.message);
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
return Node.fromJSON(schema, cleanedJson);
}
throw error;
}
} }
export function getPageId(documentName: string) { 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);
}
} }
@@ -0,0 +1,70 @@
import type RedisClient from 'ioredis';
import { EventEmitter } from 'tseep';
import type {
Pack,
RSAMessageClose,
RSAMessagePing,
RSAMessageSend,
} from './redis-sync.types';
export class CollabProxySocket extends EventEmitter {
private readonly replyTo: string;
private readonly serverChannel: string;
private readonly socketId: string;
private pub: RedisClient;
private readonly pack: Pack;
readyState = 1;
constructor(
pub: RedisClient,
pack: Pack,
replyTo: string,
serverChannel: string,
socketId: string,
) {
super();
this.replyTo = replyTo;
this.socketId = socketId;
this.serverChannel = serverChannel;
this.pub = pub;
this.pack = pack;
this.once('close', () => {
this.readyState = 3;
});
}
private publish(msg: RSAMessageClose | RSAMessagePing | RSAMessageSend) {
this.pub.publish(this.replyTo, this.pack(msg));
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
const msg: RSAMessageClose = {
type: 'close',
code,
reason,
socketId: this.socketId,
};
this.publish(msg);
}
ping() {
if (this.readyState !== 1) return;
const msg: RSAMessagePing = {
type: 'ping',
socketId: this.socketId,
replyTo: this.serverChannel,
};
this.publish(msg);
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
const msg: RSAMessageSend = {
type: 'send',
socketId: this.socketId,
message,
};
this.publish(msg);
}
}
@@ -0,0 +1,2 @@
export * from './redis-sync.extension';
export type { SerializedHTTPRequest } from './redis-sync.extension';
@@ -0,0 +1,378 @@
// Source https://github.com/ueberdosis/hocuspocus/pull/1008 - MIT
import {
Extension,
Hocuspocus,
IncomingMessage,
afterUnloadDocumentPayload,
onConfigurePayload,
onLoadDocumentPayload,
} from '@hocuspocus/server';
import RedisClient from 'ioredis';
import { readVarString } from 'lib0/decoding.js';
import { CollabProxySocket } from './collab-proxy-socket';
import {
BaseWebSocket,
Configuration,
CustomEvents,
Pack,
RSAMessage,
RSAMessageCloseProxy,
RSAMessageCustomEventComplete,
RSAMessageCustomEventStart,
RSAMessagePong,
RSAMessageProxy,
RSAMessageUnload,
SerializedHTTPRequest,
Unpack,
} from './redis-sync.types';
export type { Pack, SerializedHTTPRequest } from './redis-sync.types';
type ServerId = string;
type DocumentName = string;
type SocketId = string;
export class RedisSyncExtension<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);
}
}
@@ -0,0 +1,121 @@
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;
};
@@ -0,0 +1,47 @@
import { EventEmitter } from 'events';
import type WebSocket from 'ws';
/**
* Wrapper around ws WebSocket that only receives events via emit().
* This prevents double-handling when used with RedisSyncExtension.
*/
export class WsSocketWrapper extends EventEmitter {
private ws: WebSocket;
readyState = 1;
constructor(ws: WebSocket) {
super();
this.ws = ws;
this.once('close', () => {
this.readyState = 3;
});
}
close(code?: number, reason?: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
try {
this.ws.close(code, reason);
} catch (e) {
// Socket already closed
}
}
ping() {
if (this.readyState !== 1) return;
try {
this.ws.ping();
} catch (e) {
// Socket already closed
}
}
send(message: Uint8Array) {
if (this.readyState !== 1) return;
try {
this.ws.send(message);
} catch (e) {
// Socket already closed
}
}
}
@@ -12,12 +12,15 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
CollabAppModule, CollabAppModule,
new FastifyAdapter({ new FastifyAdapter({
ignoreTrailingSlash: true, routerOptions: {
ignoreDuplicateSlashes: true, maxParamLength: 1000,
maxParamLength: 500, ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
},
}), }),
{ {
bufferLogs: true, logger: false,
bufferLogs: false,
}, },
); );
@@ -0,0 +1,16 @@
export type ExportPageMetadata = {
pageId: string;
slugId: string;
icon: string | null;
position: string;
parentPath: string | null;
createdAt: string;
updatedAt: string;
};
export type ExportMetadata = {
exportedAt: string;
source: 'docmost';
version: string;
pages: Record<string, ExportPageMetadata>;
};
+15 -8
View File
@@ -5,13 +5,14 @@ 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 === 'production'; const isProduction = process.env.NODE_ENV?.toLowerCase() === 'production';
const isDebugMode = process.env.DEBUG_MODE === 'true'; const isDebugMode = process.env.DEBUG_MODE?.toLowerCase() === 'true';
const logHttp = process.env.LOG_HTTP === 'true'; const logHttp = process.env.LOG_HTTP?.toLowerCase() === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug'; const level = isProduction && !isDebugMode ? 'info' : 'debug';
@@ -32,14 +33,20 @@ 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) {
const context = object['context'] as string | undefined; for (const arg of inputArgs) {
if (context && CONTEXTS_TO_IGNORE.includes(context)) { if (typeof arg === 'object' && arg !== null && 'context' in arg) {
return { filtered: true }; const context = (arg as Record<string, unknown>)['context'];
if (typeof context === 'string' && CONTEXTS_TO_IGNORE.includes(context)) {
return;
}
}
} }
} }
return object; return method.apply(this, inputArgs);
}, },
}, },
serializers: { serializers: {
@@ -422,6 +422,8 @@ 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)
@@ -438,6 +440,8 @@ 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')
@@ -5,7 +5,6 @@ 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';
@@ -21,16 +20,23 @@ 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 {
@@ -83,7 +89,7 @@ export class ExportService {
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim, /<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
'', '',
); );
return turndown(newPageHtml); return htmlToMarkdown(newPageHtml);
} }
return; return;
@@ -155,12 +161,17 @@ 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[]);
@@ -189,10 +200,12 @@ 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 }[] = [ const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null }, { folder: zip, parentPageId: null },
]; ];
@@ -232,12 +245,35 @@ 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) {
+15 -5
View File
@@ -1,4 +1,5 @@
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';
@@ -88,7 +89,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); node.text = getInternalLinkPageName(relativePath, currentPagePath);
} }
} }
} }
@@ -99,10 +100,19 @@ export function replaceInternalLinks(
return doc.toJSON(); return doc.toJSON();
} }
export function getInternalLinkPageName(path: string): string { export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
return decodeURIComponent( const name = path?.split('/').pop().split('.').slice(0, -1).join('.');
path?.split('/').pop().split('.').slice(0, -1).join('.'), try {
); return decodeURIComponent(name);
} catch (err) {
if (currentFilePath) {
Logger.warn(
`URI malformed in page ${currentFilePath}: ${name}. Falling back to raw name.`,
'ExportUtils',
);
}
return name;
}
} }
export function extractPageSlugId(input: string): string { export function extractPageSlugId(input: string): string {
@@ -15,4 +15,5 @@ export type ImportPageNode = {
parentPageId: string | null; parentPageId: string | null;
fileExtension: string; fileExtension: string;
filePath: string; filePath: string;
icon?: string | null;
}; };
@@ -24,6 +24,8 @@ import { formatImportHtml } from '../utils/import-formatter';
import { import {
buildAttachmentCandidates, buildAttachmentCandidates,
collectMarkdownAndHtmlFiles, collectMarkdownAndHtmlFiles,
encodeFilePath,
readDocmostMetadata,
stripNotionID, stripNotionID,
} from '../utils/import.utils'; } from '../utils/import.utils';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
@@ -154,6 +156,7 @@ export class FileImportTaskService {
const { extractDir, fileTask } = opts; const { extractDir, fileTask } = opts;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir); const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir); const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const pagesMap = new Map<string, ImportPageNode>(); const pagesMap = new Map<string, ImportPageNode>();
@@ -164,6 +167,9 @@ export class FileImportTaskService {
.join('/'); // normalize to forward-slashes .join('/'); // normalize to forward-slashes
const ext = path.extname(relPath).toLowerCase(); const ext = path.extname(relPath).toLowerCase();
const encodedPath = encodeFilePath(relPath);
const pageMetadata = docmostMetadata?.pages[encodedPath];
pagesMap.set(relPath, { pagesMap.set(relPath, {
id: v7(), id: v7(),
slugId: generateSlugId(), slugId: generateSlugId(),
@@ -172,6 +178,7 @@ export class FileImportTaskService {
parentPageId: null, parentPageId: null,
fileExtension: ext, fileExtension: ext,
filePath: relPath, filePath: relPath,
icon: pageMetadata?.icon ?? null,
}); });
} }
@@ -224,6 +231,8 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) { if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath); const folderName = path.basename(folderPath);
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, { pagesMap.set(mdPath, {
id: v7(), id: v7(),
slugId: generateSlugId(), slugId: generateSlugId(),
@@ -232,6 +241,7 @@ export class FileImportTaskService {
parentPageId: null, parentPageId: null,
fileExtension: '.md', fileExtension: '.md',
filePath: mdPath, filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
}); });
} }
}); });
@@ -266,11 +276,39 @@ export class FileImportTaskService {
siblingsMap.set(page.parentPageId, group); siblingsMap.set(page.parentPageId, group);
}); });
const encodedPathsMap = new Map<string, string>();
if (docmostMetadata) {
pagesMap.forEach((_, filePath) => {
encodedPathsMap.set(filePath, encodeFilePath(filePath));
});
}
// Sort siblings by metadata position if available, otherwise alphabetically
const sortSiblings = (siblings: ImportPageNode[]) => {
if (docmostMetadata) {
siblings.sort((a, b) => {
const posA =
docmostMetadata.pages[encodedPathsMap.get(a.filePath)]?.position;
const posB =
docmostMetadata.pages[encodedPathsMap.get(b.filePath)]?.position;
if (posA && posB) {
// Use direct comparison to match PostgreSQL collation 'C' (byte order)
if (posA < posB) return -1;
if (posA > posB) return 1;
return 0;
}
return a.name.localeCompare(b.name);
});
} else {
siblings.sort((a, b) => a.name.localeCompare(b.name));
}
};
// get root pages // get root pages
const rootSibs = siblingsMap.get(null); const rootSibs = siblingsMap.get(null);
if (rootSibs?.length) { if (rootSibs?.length) {
rootSibs.sort((a, b) => a.name.localeCompare(b.name)); sortSiblings(rootSibs);
// get first position key from the server // get first position key from the server
const nextPosition = await this.pageService.nextPagePosition( const nextPosition = await this.pageService.nextPagePosition(
@@ -292,7 +330,7 @@ export class FileImportTaskService {
siblingsMap.forEach((sibs, parentId) => { siblingsMap.forEach((sibs, parentId) => {
if (parentId === null) return; // root already done if (parentId === null) return; // root already done
sibs.sort((a, b) => a.name.localeCompare(b.name)); sortSiblings(sibs);
let prevPos: string | null = null; let prevPos: string | null = null;
for (const page of sibs) { for (const page of sibs) {
@@ -426,7 +464,7 @@ export class FileImportTaskService {
id: page.id, id: page.id,
slugId: page.slugId, slugId: page.slugId,
title: title || page.name, title: title || page.name,
icon: pageIcon || null, icon: page.icon || pageIcon || null,
content: prosemirrorJson, content: prosemirrorJson,
textContent: jsonToText(prosemirrorJson), textContent: jsonToText(prosemirrorJson),
ydoc: await this.importService.createYdoc(prosemirrorJson), ydoc: await this.importService.createYdoc(prosemirrorJson),
@@ -1,4 +1,5 @@
import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
import { Logger } from '@nestjs/common';
import * as path from 'path'; import * as path from 'path';
import { v7 } from 'uuid'; import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types'; import { InsertableBacklink } from '@docmost/db/types/entity.types';
@@ -280,8 +281,18 @@ export async function rewriteInternalLinksToMentionHtml(
const $a = $(el); const $a = $(el);
const raw = $a.attr('href')!; const raw = $a.attr('href')!;
if (raw.startsWith('http') || raw.startsWith('/api/')) return; if (raw.startsWith('http') || raw.startsWith('/api/')) return;
let decodedRaw = raw;
try {
decodedRaw = decodeURIComponent(raw);
} catch (err) {
Logger.warn(
`URI malformed in page ${currentFilePath}: ${raw}. Falling back to raw path.`,
'ImportFormatter',
);
}
const resolved = normalize( const resolved = normalize(
path.join(path.dirname(currentFilePath), decodeURIComponent(raw)), path.join(path.dirname(currentFilePath), decodedRaw),
); );
const meta = filePathToPageMetaMap.get(resolved); const meta = filePathToPageMetaMap.get(resolved);
if (!meta) return; if (!meta) return;
@@ -1,5 +1,7 @@
import { Logger } from '@nestjs/common';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ExportMetadata } from '../../../common/helpers/types/export-metadata.types';
export async function buildAttachmentCandidates( export async function buildAttachmentCandidates(
extractDir: string, extractDir: string,
@@ -30,8 +32,19 @@ export function resolveRelativeAttachmentPath(
pageDir: string, pageDir: string,
attachmentCandidates: Map<string, string>, attachmentCandidates: Map<string, string>,
): string | null { ): string | null {
const mainRel = decodeURIComponent(raw.replace(/^\.?\/+/, '')); let mainRel = raw.replace(/^\.?\/+/, '');
const fallback = path.normalize(path.join(pageDir, mainRel)); try {
mainRel = decodeURIComponent(mainRel);
} catch (err) {
Logger.warn(
`URI malformed for attachment path: ${mainRel}. Falling back to raw path.`,
'ImportUtils',
);
}
const fallback = path
.normalize(path.join(pageDir, mainRel))
.split(path.sep)
.join('/');
if (attachmentCandidates.has(mainRel)) { if (attachmentCandidates.has(mainRel)) {
return mainRel; return mainRel;
@@ -70,3 +83,26 @@ export function stripNotionID(fileName: string): string {
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i; const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
return fileName.replace(notionIdPattern, '').trim(); return fileName.replace(notionIdPattern, '').trim();
} }
export function encodeFilePath(filePath: string): string {
return filePath
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export async function readDocmostMetadata(
extractDir: string,
): Promise<ExportMetadata | null> {
const metadataPath = path.join(extractDir, 'docmost-metadata.json');
try {
const content = await fs.readFile(metadataPath, 'utf-8');
const metadata = JSON.parse(content) as ExportMetadata;
if (metadata.source === 'docmost' && metadata.pages) {
return metadata;
}
return null;
} catch {
return null;
}
}
+8 -2
View File
@@ -24,7 +24,11 @@ async function bootstrap() {
}), }),
{ {
rawBody: true, rawBody: true,
bufferLogs: true, // disable Nest logger so pino handles all logs
// bufferLogs must be false else pino will fail
// to log OnApplicationBootstrap logs
logger: false,
bufferLogs: false,
}, },
); );
@@ -101,7 +105,9 @@ async function bootstrap() {
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => { await app.listen(port, '0.0.0.0', () => {
logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`); logger.log(
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
}); });
} }
@@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter {
const pubClient = new Redis(process.env.REDIS_URL, options); const pubClient = new Redis(process.env.REDIS_URL, options);
const subClient = new Redis(process.env.REDIS_URL, options); const subClient = new Redis(process.env.REDIS_URL, options);
pubClient.on('error', (err) => () => {});
subClient.on('error', (err) => () => {});
this.adapterConstructor = createAdapter(pubClient, subClient); this.adapterConstructor = createAdapter(pubClient, subClient);
} }
+1 -1
View File
@@ -23,7 +23,6 @@
"@casl/ability": "6.8.0", "@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3", "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "3.4.3",
"@hocuspocus/provider": "3.4.3", "@hocuspocus/provider": "3.4.3",
"@hocuspocus/server": "3.4.3", "@hocuspocus/server": "3.4.3",
"@hocuspocus/transformer": "3.4.3", "@hocuspocus/transformer": "3.4.3",
@@ -79,6 +78,7 @@
"devDependencies": { "devDependencies": {
"@nx/js": "20.4.5", "@nx/js": "20.4.5",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"nx": "20.4.5", "nx": "20.4.5",
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
@@ -96,7 +96,7 @@ export const Attachment = Node.create<AttachmentOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes
), ),
[ [
"a", "a",
@@ -99,7 +99,7 @@ export const embedProviders: IEmbedProvider[] = [
id: "gsheets", id: "gsheets",
name: "Google Sheets", name: "Google Sheets",
regex: regex:
/^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/e\/([a-zA-Z0-9_-]+)\/.*$/, /^((?:https?:)?\/\/)?((?:www|m)\.)?(docs\.google\.com)\/spreadsheets\/d\/([a-zA-Z0-9_-]+)\/.*$/,
getEmbedUrl: (match, url: string) => { getEmbedUrl: (match, url: string) => {
return url; return url;
}, },
+1 -1
View File
@@ -25,7 +25,7 @@ declare module "@tiptap/core" {
imageBlock: { imageBlock: {
setImage: (attributes: ImageAttributes) => ReturnType; setImage: (attributes: ImageAttributes) => ReturnType;
setImageAt: ( setImageAt: (
attributes: ImageAttributes & { pos: number | Range }, attributes: ImageAttributes & { pos: number | Range }
) => ReturnType; ) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType; setImageWidth: (width: number) => ReturnType;
@@ -1 +1,2 @@
export * from "./utils/marked.utils"; export * from "./utils/marked.utils";
export * from "./utils/turndown.utils";
@@ -0,0 +1,29 @@
/**
* Flexible `basename` implementation for node and the browser
* @see https://stackoverflow.com/a/59907288/2228771
*/
export function getBasename(path: string) {
// make sure the basename is not empty, if string ends with separator
let end = path.length - 1;
while (path[end] === '/' || path[end] === '\\') {
--end;
}
// support mixing of Win + Unix path separators
const i1 = path.lastIndexOf('/', end);
const i2 = path.lastIndexOf('\\', end);
let start: number;
if (i1 === -1) {
if (i2 === -1) {
// no separator in the whole thing
return path;
}
start = i2;
} else if (i2 === -1) {
start = i1;
} else {
start = Math.max(i1, i2);
}
return path.substring(start + 1, end + 1);
}
@@ -0,0 +1,12 @@
// Map @joplin/turndown types to @types/turndown
declare module "@joplin/turndown" {
import TurndownService from "turndown";
export = TurndownService;
}
declare module "@joplin/turndown-plugin-gfm" {
import TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const strikethrough: TurndownService.Plugin;
export const highlightedCodeBlock: TurndownService.Plugin;
}
@@ -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 * as path from 'path'; import { getBasename } from './basename';
export function turndown(html: string): string { // CJS/ESM interop: .default exists in Vite, not in NestJS
const TurndownService = (_TurndownService as any).default || _TurndownService;
export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({ 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([
tables, TurndownPluginGfm.tables,
strikethrough, TurndownPluginGfm.strikethrough,
highlightedCodeBlock, TurndownPluginGfm.highlightedCodeBlock,
taskList, taskList,
callout, callout,
preserveDetail, preserveDetail,
@@ -29,34 +29,33 @@ export function turndown(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: any, node: HTMLInputElement) => { replacement: (content: string, 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: any, node: HTMLInputElement) { replacement: function (content: string, 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 (
@@ -64,32 +63,36 @@ function taskList(turndownService: TurndownService) {
node.parentNode.nodeName === 'UL' node.parentNode.nodeName === 'UL'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string, node: HTMLInputElement) {
const checkbox = node.querySelector( const checkbox = node.querySelector(
'input[type="checkbox"]', 'input[type="checkbox"]',
) as HTMLInputElement; ) as HTMLInputElement;
const isChecked = checkbox.checked; const isChecked = checkbox.checked;
// Process content like regular list items // Process content like regular list items
content = content content = content
.replace(/^\n+/, '') // remove leading newlines .replace(/^\n+/, '') // remove leading newlines
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one .replace(/\n+$/, '\n') // replace trailing newlines with just a single one
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces .replace(/\n/gm, '\n '); // indent nested content with 2 spaces
// Create the checkbox prefix // Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `; const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : ''); return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
}, },
}); });
} }
function preserveDetail(turndownService: TurndownService) { function preserveDetail(turndownService: _TurndownService) {
turndownService.addRule('preserveDetail', { turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return node.nodeName === 'DETAILS'; return node.nodeName === 'DETAILS';
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const summary = node.querySelector(':scope > summary'); const summary = node.querySelector(':scope > summary');
let detailSummary = ''; let detailSummary = '';
@@ -111,7 +114,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 (
@@ -119,13 +122,13 @@ function mathInline(turndownService: TurndownService) {
node.getAttribute('data-type') === 'mathInline' node.getAttribute('data-type') === 'mathInline'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string) {
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 (
@@ -133,32 +136,32 @@ function mathBlock(turndownService: TurndownService) {
node.getAttribute('data-type') === 'mathBlock' node.getAttribute('data-type') === 'mathBlock'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: string) {
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: any, node: HTMLInputElement) { replacement: function (_content: string, 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: any, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || ''; const src = node.getAttribute('src') || '';
const name = path.basename(src); const name = getBasename(src) || src;
return '[' + name + '](' + src + ')'; return '[' + name + '](' + src + ')';
}, },
}); });
+1 -1
View File
@@ -23,7 +23,7 @@ declare module "@tiptap/core" {
videoBlock: { videoBlock: {
setVideo: (attributes: VideoAttributes) => ReturnType; setVideo: (attributes: VideoAttributes) => ReturnType;
setVideoAt: ( setVideoAt: (
attributes: VideoAttributes & { pos: number | Range }, attributes: VideoAttributes & { pos: number | Range }
) => ReturnType; ) => ReturnType;
setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
setVideoWidth: (width: number) => ReturnType; setVideoWidth: (width: number) => ReturnType;
+37 -57
View File
@@ -30,9 +30,6 @@ importers:
'@floating-ui/dom': '@floating-ui/dom':
specifier: ^1.7.3 specifier: ^1.7.3
version: 1.7.3 version: 1.7.3
'@hocuspocus/extension-redis':
specifier: 3.4.3
version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
'@hocuspocus/provider': '@hocuspocus/provider':
specifier: 3.4.3 specifier: 3.4.3
version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29) version: 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
@@ -193,6 +190,9 @@ importers:
'@types/bytes': '@types/bytes':
specifier: ^3.1.5 specifier: ^3.1.5
version: 3.1.5 version: 3.1.5
'@types/turndown':
specifier: ^5.0.6
version: 5.0.6
'@types/uuid': '@types/uuid':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
@@ -551,12 +551,18 @@ importers:
ldapts: ldapts:
specifier: ^7.4.0 specifier: ^7.4.0
version: 7.4.0 version: 7.4.0
lib0:
specifier: ^0.2.117
version: 0.2.117
mammoth: mammoth:
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.11.0 version: 1.11.0
mime-types: mime-types:
specifier: ^2.1.35 specifier: ^2.1.35
version: 2.1.35 version: 2.1.35
msgpackr:
specifier: ^1.11.8
version: 1.11.8
nanoid: nanoid:
specifier: 3.3.11 specifier: 3.3.11
version: 3.3.11 version: 3.3.11
@@ -629,6 +635,9 @@ importers:
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
tseep:
specifier: ^1.3.1
version: 1.3.1
typesense: typesense:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0(@babel/runtime@7.25.6) version: 2.1.0(@babel/runtime@7.25.6)
@@ -2380,12 +2389,6 @@ packages:
'@hocuspocus/common@3.4.3': '@hocuspocus/common@3.4.3':
resolution: {integrity: sha512-wnBBO9sWcVAoUPEXN1qO+zk3HaEF9VTemxB6kjuuH6e1dHnD0v12m4P4X1wiZVhmMIX/PMl/fu3MGtYWQJz8gA==} resolution: {integrity: sha512-wnBBO9sWcVAoUPEXN1qO+zk3HaEF9VTemxB6kjuuH6e1dHnD0v12m4P4X1wiZVhmMIX/PMl/fu3MGtYWQJz8gA==}
'@hocuspocus/extension-redis@3.4.3':
resolution: {integrity: sha512-r64Vpgk6tt0VZaQPEo1dQuyur2ozr243ncDcDM+4gFPuV8ZRUjL1rvaJTidb2HCcAW2zjfwshNxw4+OixeksBA==}
peerDependencies:
y-protocols: ^1.0.6
yjs: ^13.6.8
'@hocuspocus/provider@3.4.3': '@hocuspocus/provider@3.4.3':
resolution: {integrity: sha512-zt+UgVXGsEQrqnDZgavc2PT9yKJjmVjV+5YxvhlmFVFLVORqawT4l601aKmLPhvyK97un4ZApZ5rso8iO6crWg==} resolution: {integrity: sha512-zt+UgVXGsEQrqnDZgavc2PT9yKJjmVjV+5YxvhlmFVFLVORqawT4l601aKmLPhvyK97un4ZApZ5rso8iO6crWg==}
peerDependencies: peerDependencies:
@@ -3881,12 +3884,6 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@sesamecare-oss/redlock@1.4.0':
resolution: {integrity: sha512-2z589R+yxKLN4CgKxP1oN4dsg6Y548SE4bVYam/R0kHk7Q9VrQ9l66q+k1ehhSLLY4or9hcchuF9/MhuuZdjJg==}
engines: {node: '>=16'}
peerDependencies:
ioredis: '>=5'
'@sinclair/typebox@0.27.8': '@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -4812,6 +4809,9 @@ packages:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -7587,13 +7587,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lib0@0.2.114: lib0@0.2.117:
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
engines: {node: '>=16'}
hasBin: true
lib0@0.2.88:
resolution: {integrity: sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
@@ -7969,8 +7964,8 @@ packages:
resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==}
hasBin: true hasBin: true
msgpackr@1.11.2: msgpackr@1.11.8:
resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==}
multimath@2.0.0: multimath@2.0.0:
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
@@ -9665,6 +9660,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tseep@1.3.1:
resolution: {integrity: sha512-ZPtfk1tQnZVyr7BPtbJ93qaAh2lZuIOpTMjhrYa4XctT8xe7t4SAW9LIxrySDuYMsfNNayE51E/WNGrNVgVicQ==}
tslib@2.8.0: tslib@2.8.0:
resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==}
@@ -12653,27 +12651,13 @@ snapshots:
'@hocuspocus/common@3.4.3': '@hocuspocus/common@3.4.3':
dependencies: dependencies:
lib0: 0.2.114 lib0: 0.2.117
'@hocuspocus/extension-redis@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)':
dependencies:
'@hocuspocus/server': 3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)
'@sesamecare-oss/redlock': 1.4.0(ioredis@5.8.2)
ioredis: 5.8.2
kleur: 4.1.5
lodash.debounce: 4.0.8
y-protocols: 1.0.6(yjs@13.6.29)
yjs: 13.6.29
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@hocuspocus/provider@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': '@hocuspocus/provider@3.4.3(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)':
dependencies: dependencies:
'@hocuspocus/common': 3.4.3 '@hocuspocus/common': 3.4.3
'@lifeomic/attempt': 3.0.3 '@lifeomic/attempt': 3.0.3
lib0: 0.2.114 lib0: 0.2.117
ws: 8.19.0 ws: 8.19.0
y-protocols: 1.0.6(yjs@13.6.29) y-protocols: 1.0.6(yjs@13.6.29)
yjs: 13.6.29 yjs: 13.6.29
@@ -12687,7 +12671,7 @@ snapshots:
async-lock: 1.4.1 async-lock: 1.4.1
async-mutex: 0.5.0 async-mutex: 0.5.0
kleur: 4.1.5 kleur: 4.1.5
lib0: 0.2.114 lib0: 0.2.117
ws: 8.19.0 ws: 8.19.0
y-protocols: 1.0.6(yjs@13.6.29) y-protocols: 1.0.6(yjs@13.6.29)
yjs: 13.6.29 yjs: 13.6.29
@@ -14147,10 +14131,6 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@sesamecare-oss/redlock@1.4.0(ioredis@5.8.2)':
dependencies:
ioredis: 5.8.2
'@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.27.8': {}
'@sindresorhus/slugify@1.1.0': '@sindresorhus/slugify@1.1.0':
@@ -14861,7 +14841,7 @@ snapshots:
'@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)': '@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29)':
dependencies: dependencies:
lib0: 0.2.114 lib0: 0.2.117
prosemirror-model: 1.25.1 prosemirror-model: 1.25.1
prosemirror-state: 1.4.3 prosemirror-state: 1.4.3
prosemirror-view: 1.40.0 prosemirror-view: 1.40.0
@@ -15274,6 +15254,8 @@ snapshots:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
optional: true optional: true
'@types/turndown@5.0.6': {}
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/unist@3.0.2': {} '@types/unist@3.0.2': {}
@@ -16057,7 +16039,7 @@ snapshots:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
ioredis: 5.8.2 ioredis: 5.8.2
msgpackr: 1.11.2 msgpackr: 1.11.8
node-abort-controller: 3.1.1 node-abort-controller: 3.1.1
semver: 7.7.2 semver: 7.7.2
tslib: 2.8.1 tslib: 2.8.1
@@ -18679,11 +18661,7 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lib0@0.2.114: lib0@0.2.117:
dependencies:
isomorphic.js: 0.2.5
lib0@0.2.88:
dependencies: dependencies:
isomorphic.js: 0.2.5 isomorphic.js: 0.2.5
@@ -19158,7 +19136,7 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2
optional: true optional: true
msgpackr@1.11.2: msgpackr@1.11.8:
optionalDependencies: optionalDependencies:
msgpackr-extract: 3.0.2 msgpackr-extract: 3.0.2
@@ -21038,6 +21016,8 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-bom: 3.0.0 strip-bom: 3.0.0
tseep@1.3.1: {}
tslib@2.8.0: {} tslib@2.8.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
@@ -21511,12 +21491,12 @@ snapshots:
y-indexeddb@9.0.12(yjs@13.6.29): y-indexeddb@9.0.12(yjs@13.6.29):
dependencies: dependencies:
lib0: 0.2.88 lib0: 0.2.117
yjs: 13.6.29 yjs: 13.6.29
y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29): y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.29))(yjs@13.6.29):
dependencies: dependencies:
lib0: 0.2.114 lib0: 0.2.117
prosemirror-model: 1.25.1 prosemirror-model: 1.25.1
prosemirror-state: 1.4.3 prosemirror-state: 1.4.3
prosemirror-view: 1.40.0 prosemirror-view: 1.40.0
@@ -21525,7 +21505,7 @@ snapshots:
y-protocols@1.0.6(yjs@13.6.29): y-protocols@1.0.6(yjs@13.6.29):
dependencies: dependencies:
lib0: 0.2.114 lib0: 0.2.117
yjs: 13.6.29 yjs: 13.6.29
y18n@4.0.3: {} y18n@4.0.3: {}
@@ -21578,7 +21558,7 @@ snapshots:
yjs@13.6.29: yjs@13.6.29:
dependencies: dependencies:
lib0: 0.2.114 lib0: 0.2.117
yn@3.1.1: {} yn@3.1.1: {}