Compare commits

..

37 Commits

Author SHA1 Message Date
Philipinho 2f92e1fecf add yjs utils 2026-02-12 11:13:28 -08:00
Philipinho e709e34f1f dry 2026-02-12 11:09:08 -08:00
Philipinho ae9484e274 rename contentOperation -> operation 2026-02-12 11:01:00 -08:00
Philipinho 3c81441ddb refactor naming
* support prepend
2026-02-12 10:57:30 -08:00
Philipinho 152702ebe0 import module 2026-02-11 23:50:24 -08:00
Philipinho 10b0ac06dd feat: page content update and retrieval output 2026-02-11 23:44:46 -08:00
Philipinho 49ab9875ba fix tiptap version conflicts 2026-02-11 22:47:25 -08:00
Philipinho 25f4b8c2b4 fix 2026-02-11 17:47:30 -08:00
Philipinho 4d43f86c51 update deps 2026-02-11 17:43:13 -08:00
Philip Okugbe f170ede8da fix(deps): override packages (#1936)
* override packages
2026-02-11 16:48:26 -08:00
Philipinho 7861b5b186 fix: add RedisModule to CollabAppModule 2026-02-09 18:50:31 -08:00
Philipinho 3a9bdfbb06 fix(deps): update vite and nx 2026-02-09 18:32:09 -08:00
Philipinho ab7999a946 v0.25.3 2026-02-09 18:27:55 -08:00
Philip Okugbe 0f02261ee6 feat: page version history improvements (#1925)
* Refactor: use queue for page history

* feat: save multiple version contributors

* display contributor avatars in history list

* fix interval
2026-02-09 18:25:35 -08:00
Philip Okugbe aff8dba2cb fix: diagrams SVG content length (#1928) 2026-02-09 18:20:09 -08:00
Olivier Lambert f6a8247c48 fix: cursor jumps to end of text when editing a comment (#1924)
* fix: cursor jumps to end of text when editing a comment

When editing a comment mid-text, the cursor would jump to the end after
every keystroke, making it impossible to insert text at any position
other than the end.

Root cause: on each keystroke, the comment editor's onUpdate callback
updated parent state (setContent), which changed the defaultContent prop
passed back to CommentEditor. A useEffect watching defaultContent then
called commentEditor.commands.setContent(), which reset the entire
editor content and moved the cursor to the end.

Fix:
- Store in-progress edits in a ref instead of state to avoid triggering
  React re-renders and the prop->effect->setContent cascade
- Read from the ref when saving the comment
- Sync the ref back into state after a successful save so the read-only
  view updates immediately
- Guard the setContent useEffect to only run for read-only editors, so
  websocket-driven updates from other browsers still work

Fixes #1791

Functionally tested on Firefox and Chrome: mid-text editing, saving,
cross-browser live updates via websocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix stale content on edit cancel

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2026-02-09 15:16:40 -08:00
Philip Okugbe 7879e1f600 fix: add execCommand fallback for clipboard (#1927)
* fix: add execCommand fallback for clipboard
2026-02-09 14:44:27 -08:00
Philip Okugbe 3cb70f0696 New translations translation.json (German) (#1915) 2026-02-06 11:37:33 -08:00
Philipinho fbb44df548 v0.25.2 2026-02-06 11:32:00 -08:00
Philip Okugbe bc3ce893c4 New Crowdin updates (#1914)
* 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 (Portuguese, Brazilian)
2026-02-06 11:31:12 -08:00
Philipinho ae96352189 sync 2026-02-06 10:37:51 -08:00
Philip Okugbe 1ad53c2581 feat(ee): public sharing controls (#1910)
* feat(ee): public sharing controls
* lint
2026-02-06 10:35:36 -08:00
Philip Okugbe 2f97a3debc feat: DOCX import (#1913) 2026-02-06 10:34:51 -08:00
Philipinho 40b5346f9e cleanup redundant param 2026-02-06 10:28:52 -08:00
Philipinho d6b4573b79 update compose services versions 2026-02-06 10:27:34 -08:00
Philip Okugbe 4878850b25 fix: attachment bugs in safari(#1908)
* use widely available arrayBuffer
* fix stream fails in safari
* fix hasFocus bug
* fix safari upload bug
* feat: add HTTP range request support for file serving
2026-02-05 07:47:03 -08:00
Philip Okugbe 5c3942c159 fix safari print (#1907) 2026-02-04 08:26:03 -08:00
Philipinho e0809e7104 v0.25.1 2026-02-04 07:10:13 -08:00
Philipinho da6793ac87 downgrade tiptap version (fix menu) 2026-02-04 07:09:48 -08:00
Philip Okugbe 08e94eb3c1 update dependencies (#1902) 2026-02-03 15:15:23 -08:00
Philipinho 5a14186f1c fix global diff css 2026-02-03 13:47:56 -08:00
Philipinho 6a0bb8d4cb v0.25.0 2026-02-03 13:18:03 -08:00
Philip Okugbe fba9f4cb2b New Crowdin updates (#1896)
* 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)

* 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)

* 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-02-03 13:16:27 -08:00
Philipinho d8f7c4a822 cleanup 2026-02-03 13:12:39 -08:00
Philipinho 202685b39f fix translation 2026-02-03 13:09:56 -08:00
Philip Okugbe fc4a428208 fix(deps): update dependencies (#1898) 2026-02-03 13:04:00 -08:00
Philip Okugbe 5506eb194b feat: page history diff (#1891)
* Show actual history changes
* V2 - WIP
* feat: page history diff
* fix: exclude content from history listing

---------

Co-authored-by: Jason Norwood-Young <jason@10layer.com>
2026-02-03 11:55:20 -08:00
102 changed files with 7347 additions and 5559 deletions
+12 -12
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.25.0-beta.1", "version": "0.25.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -14,18 +14,18 @@
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-c158187", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.12", "@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.12", "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.12", "@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.12", "@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.12", "@mantine/spotlight": "^8.3.14",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -41,7 +41,7 @@
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "1.345.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.17",
@@ -66,7 +66,7 @@
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
@@ -41,7 +41,7 @@
"Date": "Datum", "Date": "Datum",
"Delete": "Löschen", "Delete": "Löschen",
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dabei werden auch alle Unterseiten und der Seitenverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Details", "Details": "Details",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
@@ -66,7 +66,7 @@
"Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein", "Enter your new preferred email": "Geben Sie Ihre neue bevorzugte E-Mail ein",
"Enter your password": "Geben Sie Ihr Passwort ein", "Enter your password": "Geben Sie Ihr Passwort ein",
"Error fetching page data.": "Fehler beim Abrufen der Seitendaten.", "Error fetching page data.": "Fehler beim Abrufen der Seitendaten.",
"Error loading page history.": "Fehler beim Laden der Seitengeschichte.", "Error loading page history.": "Fehler beim Laden des Seitenverlaufs.",
"Export": "Exportieren", "Export": "Exportieren",
"Failed to create page": "Erstellung der Seite fehlgeschlagen", "Failed to create page": "Erstellung der Seite fehlgeschlagen",
"Failed to delete page": "Löschen der Seite fehlgeschlagen", "Failed to delete page": "Löschen der Seite fehlgeschlagen",
@@ -114,7 +114,7 @@
"New page": "Neue Seite", "New page": "Neue Seite",
"New password": "Neues Passwort", "New password": "Neues Passwort",
"No group found": "Keine Gruppe gefunden", "No group found": "Keine Gruppe gefunden",
"No page history saved yet.": "Es wurde noch keine Seitengeschichte gespeichert.", "No page history saved yet.": "Es wurde noch kein Seitenverlauf gespeichert.",
"No pages yet": "Noch keine Seiten", "No pages yet": "Noch keine Seiten",
"No results found...": "Keine Ergebnisse gefunden...", "No results found...": "Keine Ergebnisse gefunden...",
"No user found": "Kein Benutzer gefunden", "No user found": "Kein Benutzer gefunden",
@@ -122,7 +122,9 @@
"Owner": "Besitzer", "Owner": "Besitzer",
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte", "Page history": "Seitenverlauf",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten", "Pages": "Seiten",
"pages": "Seiten", "pages": "Seiten",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden", "Share not found": "Freigabe nicht gefunden",
"Failed to share page": "Fehler beim Teilen der Seite", "Failed to share page": "Fehler beim Teilen der Seite",
"Disable public sharing": "Öffentliches Teilen deaktivieren",
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Requires an enterprise license": "Erfordert eine Unternehmenslizenz",
"Enable public sharing": "Öffentliches Teilen aktivieren",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sind Sie sicher, dass Sie das öffentliche Teilen aktivieren möchten? Mitglieder können Seiten öffentlich teilen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Arbeitsbereich werden gelöscht.",
"Are you sure you want to enable public sharing for this space?": "Sind Sie sicher, dass Sie das öffentliche Teilen für diesen Bereich aktivieren möchten?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sind Sie sicher, dass Sie das öffentliche Teilen deaktivieren möchten? Alle bestehenden Freigabelinks in diesem Bereich werden gelöscht.",
"Public sharing is disabled": "Öffentliches Teilen ist deaktiviert",
"Public sharing has been disabled at the workspace level.": "Das öffentliche Teilen wurde auf der Arbeitsbereichsebene deaktiviert.",
"Public sharing has been disabled for this space.": "Das öffentliche Teilen wurde für diesen Bereich deaktiviert.",
"Copy page": "Seite kopieren", "Copy page": "Seite kopieren",
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.", "Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
"Page copied successfully": "Seite erfolgreich kopiert", "Page copied successfully": "Seite erfolgreich kopiert",
@@ -123,10 +123,7 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Version history for": "Version history for",
"document": "document",
"Select version": "Select version", "Select version": "Select version",
"Close": "Close",
"Highlight changes": "Highlight changes", "Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages", "Pages": "Pages",
@@ -410,6 +407,21 @@
"Share deleted successfully": "Share deleted successfully", "Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "Share not found": "Share not found",
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Disable public sharing": "Disable public sharing",
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Enable public sharing": "Enable public sharing",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
"Public sharing is disabled": "Public sharing is disabled",
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
@@ -123,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página eliminada con éxito", "Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página", "Page history": "Historial de la página",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Compartición eliminada con éxito", "Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada", "Share not found": "Compartición no encontrada",
"Failed to share page": "Error al compartir la página", "Failed to share page": "Error al compartir la página",
"Disable public sharing": "Desactivar el uso compartido público",
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Requires an enterprise license": "Requiere una licencia empresarial",
"Enable public sharing": "Activar el uso compartido público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "¿Está seguro de que desea activar el uso compartido público? Los miembros podrán compartir páginas públicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio de trabajo se eliminarán.",
"Are you sure you want to enable public sharing for this space?": "¿Está seguro de que desea activar el uso compartido público para este espacio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "¿Está seguro de que desea desactivar el uso compartido público? Todos los enlaces compartidos existentes en este espacio se eliminarán.",
"Public sharing is disabled": "El uso compartido público está desactivado",
"Public sharing has been disabled at the workspace level.": "El uso compartido público se ha desactivado a nivel de espacio de trabajo.",
"Public sharing has been disabled for this space.": "El uso compartido público se ha desactivado para este espacio.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página en otro espacio", "Copy page to a different space.": "Copiar página en otro espacio",
"Page copied successfully": "Página copiada exitosamente", "Page copied successfully": "Página copiada exitosamente",
@@ -123,6 +123,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page supprimée avec succès", "Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page", "Page history": "Historique de la page",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Partage supprimé avec succès", "Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé", "Share not found": "Partage non trouvé",
"Failed to share page": "Échec du partage de la page", "Failed to share page": "Échec du partage de la page",
"Disable public sharing": "Désactiver le partage public",
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Requires an enterprise license": "Nécessite une licence d'entreprise",
"Enable public sharing": "Activer le partage public",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Êtes-vous sûr de vouloir activer le partage public ? Les membres pourront partager des pages publiquement.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace de travail seront supprimés.",
"Are you sure you want to enable public sharing for this space?": "Êtes-vous sûr de vouloir activer le partage public pour cet espace ?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Êtes-vous sûr de vouloir désactiver le partage public ? Tous les liens partagés existants dans cet espace seront supprimés.",
"Public sharing is disabled": "Le partage public est désactivé",
"Public sharing has been disabled at the workspace level.": "Le partage public a été désactivé au niveau de l'espace de travail.",
"Public sharing has been disabled for this space.": "Le partage public a été désactivé pour cet espace.",
"Copy page": "Copier la page", "Copy page": "Copier la page",
"Copy page to a different space.": "Copier la page dans un autre espace.", "Copy page to a different space.": "Copier la page dans un autre espace.",
"Page copied successfully": "Page copiée avec succès", "Page copied successfully": "Page copiée avec succès",
@@ -123,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo", "Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina", "Page history": "Cronologia della pagina",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine", "Pages": "Pagine",
"pages": "pagine", "pages": "pagine",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Condivisione eliminata con successo", "Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata", "Share not found": "Condivisione non trovata",
"Failed to share page": "Condivisione della pagina fallita", "Failed to share page": "Condivisione della pagina fallita",
"Disable public sharing": "Disabilita la condivisione pubblica",
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Requires an enterprise license": "Richiede una licenza enterprise",
"Enable public sharing": "Abilita la condivisione pubblica",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Sei sicuro di voler abilitare la condivisione pubblica? I membri potranno condividere le pagine pubblicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questa area di lavoro verranno eliminati.",
"Are you sure you want to enable public sharing for this space?": "Sei sicuro di voler abilitare la condivisione pubblica per questo spazio?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Sei sicuro di voler disabilitare la condivisione pubblica? Tutti i link condivisi esistenti in questo spazio verranno eliminati.",
"Public sharing is disabled": "La condivisione pubblica è disabilitata",
"Public sharing has been disabled at the workspace level.": "La condivisione pubblica è stata disabilitata a livello di area di lavoro.",
"Public sharing has been disabled for this space.": "La condivisione pubblica è stata disabilitata per questo spazio.",
"Copy page": "Copia pagina", "Copy page": "Copia pagina",
"Copy page to a different space.": "Copia pagina in un altro spazio.", "Copy page to a different space.": "Copia pagina in un altro spazio.",
"Page copied successfully": "Pagina copiata con successo", "Page copied successfully": "Pagina copiata con successo",
@@ -123,6 +123,8 @@
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページを削除しました", "Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Select version": "バージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
@@ -405,6 +407,21 @@
"Share deleted successfully": "共有を削除しました", "Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません", "Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました", "Failed to share page": "ページの共有に失敗しました",
"Disable public sharing": "公開共有を無効にする",
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Requires an enterprise license": "エンタープライズライセンスが必要です",
"Enable public sharing": "公開共有を有効にする",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "本当に公開共有を有効にしますか?メンバーはページを公開で共有できるようになります。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "本当に公開共有を無効にしますか?このワークスペース内のすべての既存の共有リンクが削除されます。",
"Are you sure you want to enable public sharing for this space?": "本当にこのスペースの公開共有を有効にしますか?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "本当に公開共有を無効にしますか?このスペースのすべての既存の共有リンクが削除されます。",
"Public sharing is disabled": "公開共有が無効になっています",
"Public sharing has been disabled at the workspace level.": "ワークスペースレベルで公開共有が無効になりました。",
"Public sharing has been disabled for this space.": "このスペースで公開共有が無効になりました。",
"Copy page": "ページをコピー", "Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします", "Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページをコピーしました", "Page copied successfully": "ページをコピーしました",
@@ -123,6 +123,8 @@
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
@@ -405,6 +407,21 @@
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다", "Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다", "Failed to share page": "페이지 공유에 실패했습니다",
"Disable public sharing": "공유 비활성화",
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Requires an enterprise license": "기업 라이센스가 필요합니다.",
"Enable public sharing": "공유 활성화",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
"Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
"Public sharing is disabled": "공유가 비활성화되었습니다.",
"Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사하기", "Copy page": "페이지 복사하기",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다", "Page copied successfully": "페이지가 성공적으로 복사되었습니다",
@@ -123,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd", "Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis", "Page history": "Pagina geschiedenis",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's", "Pages": "Pagina's",
"pages": "pagina's", "pages": "pagina's",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Delen succesvol verwijderd", "Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden", "Share not found": "Delen niet gevonden",
"Failed to share page": "Pagina delen mislukt", "Failed to share page": "Pagina delen mislukt",
"Disable public sharing": "Openbaar delen uitschakelen",
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Requires an enterprise license": "Vereist een bedrijfslicentie",
"Enable public sharing": "Openbaar delen inschakelen",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Weet je zeker dat je openbaar delen wilt inschakelen? Leden kunnen pagina's openbaar delen.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze werkruimte zullen worden verwijderd.",
"Are you sure you want to enable public sharing for this space?": "Weet je zeker dat je openbaar delen voor deze ruimte wilt inschakelen?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Weet je zeker dat je openbaar delen wilt uitschakelen? Alle bestaande gedeelde links in deze ruimte zullen worden verwijderd.",
"Public sharing is disabled": "Openbaar delen is uitgeschakeld",
"Public sharing has been disabled at the workspace level.": "Openbaar delen is uitgeschakeld op werkruimteniveau.",
"Public sharing has been disabled for this space.": "Openbaar delen is uitgeschakeld voor deze ruimte.",
"Copy page": "Pagina kopiëren", "Copy page": "Pagina kopiëren",
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.", "Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
"Page copied successfully": "Pagina succesvol gekopieerd", "Page copied successfully": "Pagina succesvol gekopieerd",
@@ -123,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página excluída com sucesso", "Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página", "Page history": "Histórico da página",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Compartilhamento excluído com sucesso", "Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado", "Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar página", "Failed to share page": "Falha ao compartilhar página",
"Disable public sharing": "Desativar compartilhamento público",
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Requires an enterprise license": "Requer uma licença empresarial",
"Enable public sharing": "Ativar compartilhamento público",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Tem certeza de que deseja ativar o compartilhamento público? Os membros poderão compartilhar páginas publicamente.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço de trabalho serão excluídos.",
"Are you sure you want to enable public sharing for this space?": "Tem certeza de que deseja ativar o compartilhamento público para este espaço?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Tem certeza de que deseja desativar o compartilhamento público? Todos os links compartilhados existentes neste espaço serão excluídos.",
"Public sharing is disabled": "Compartilhamento público está desativado",
"Public sharing has been disabled at the workspace level.": "O compartilhamento público foi desativado no nível do espaço de trabalho.",
"Public sharing has been disabled for this space.": "O compartilhamento público foi desativado para este espaço.",
"Copy page": "Copiar página", "Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página para um espaço diferente.", "Copy page to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso", "Page copied successfully": "Página copiada com sucesso",
@@ -123,6 +123,8 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы", "Pages": "Страницы",
"pages": "страницы", "pages": "страницы",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Общий доступ успешно удален", "Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден", "Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей", "Failed to share page": "Не удалось поделиться страницей",
"Disable public sharing": "Отключить общий доступ",
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Requires an enterprise license": "Требуется корпоративная лицензия",
"Enable public sharing": "Включить общий доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Вы уверены, что хотите включить общий доступ? Участники смогут делиться страницами публично.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом рабочем пространстве будут удалены.",
"Are you sure you want to enable public sharing for this space?": "Вы уверены, что хотите включить общий доступ для этого пространства?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Вы уверены, что хотите отключить общий доступ? Все существующие ссылки в этом пространстве будут удалены.",
"Public sharing is disabled": "Общий доступ отключен",
"Public sharing has been disabled at the workspace level.": "Общий доступ был отключен на уровне рабочего пространства.",
"Public sharing has been disabled for this space.": "Общий доступ был отключен для этого пространства.",
"Copy page": "Копировать страницу", "Copy page": "Копировать страницу",
"Copy page to a different space.": "Копировать страницу в другое пространство.", "Copy page to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована", "Page copied successfully": "Страница успешно скопирована",
@@ -123,6 +123,8 @@
"page": "сторінка", "page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено", "Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки", "Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки", "Pages": "Сторінки",
"pages": "сторінки", "pages": "сторінки",
@@ -405,6 +407,21 @@
"Share deleted successfully": "Спільний доступ успішно видалено", "Share deleted successfully": "Спільний доступ успішно видалено",
"Share not found": "Спільний доступ не знайдено", "Share not found": "Спільний доступ не знайдено",
"Failed to share page": "Не вдалося поділитися сторінкою", "Failed to share page": "Не вдалося поділитися сторінкою",
"Disable public sharing": "Вимкнути публічний доступ",
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Requires an enterprise license": "Потребує корпоративної ліцензії",
"Enable public sharing": "Увімкнути публічний доступ",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Ви впевнені, що хочете увімкнути публічний доступ? Учасники зможуть публічно ділитися сторінками.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому робочому просторі будуть видалені.",
"Are you sure you want to enable public sharing for this space?": "Ви впевнені, що хочете увімкнути публічний доступ для цього простору?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Ви впевнені, що хочете вимкнути публічний доступ? Усі існуючі посилання для спільного доступу в цьому просторі будуть видалені.",
"Public sharing is disabled": "Публічний доступ вимкнуто",
"Public sharing has been disabled at the workspace level.": "Публічний доступ було вимкнено на рівні робочого простору.",
"Public sharing has been disabled for this space.": "Публічний доступ було вимкнено для цього простору.",
"Copy page": "Копіювати сторінки", "Copy page": "Копіювати сторінки",
"Copy page to a different space.": "Скопіювати сторінку в інший простір.", "Copy page to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано", "Page copied successfully": "Сторінку успішно скопійовано",
@@ -123,6 +123,8 @@
"page": "个页面", "page": "个页面",
"Page deleted successfully": "页面已成功删除", "Page deleted successfully": "页面已成功删除",
"Page history": "页面历史", "Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面", "Pages": "页面",
"pages": "个页面", "pages": "个页面",
@@ -405,6 +407,21 @@
"Share deleted successfully": "分享已成功删除", "Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享", "Share not found": "未找到分享",
"Failed to share page": "页面分享失败", "Failed to share page": "页面分享失败",
"Disable public sharing": "禁用公开分享",
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Requires an enterprise license": "需要企业许可证",
"Enable public sharing": "启用公开分享",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "您确定要启用公开分享吗?成员将能够公开分享页面。",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "您确定要禁用公开分享吗?此工作区中的所有现有共享链接都将被删除。",
"Are you sure you want to enable public sharing for this space?": "您确定要为此空间启用公开分享吗?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "您确定要禁用公开分享吗?此空间中的所有现有共享链接都将被删除。",
"Public sharing is disabled": "公开分享已被禁用",
"Public sharing has been disabled at the workspace level.": "公开分享已在工作区级别被禁用。",
"Public sharing has been disabled for this space.": "此空间的公开分享已被禁用。",
"Copy page": "复制页面", "Copy page": "复制页面",
"Copy page to a different space.": "将页面复制到不同的空间。", "Copy page to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功", "Page copied successfully": "页面复制成功",
@@ -0,0 +1,33 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+2 -1
View File
@@ -1,4 +1,5 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -0,0 +1,12 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -8,10 +8,10 @@ import {
Group, Group,
List, List,
Code, Code,
CopyButton,
Alert, Alert,
PasswordInput, PasswordInput,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconRefresh, IconRefresh,
IconCopy, IconCopy,
@@ -11,7 +11,6 @@ import {
PinInput, PinInput,
Alert, Alert,
List, List,
CopyButton,
ActionIcon, ActionIcon,
Tooltip, Tooltip,
Paper, Paper,
@@ -20,6 +19,7 @@ import {
Collapse, Collapse,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { import {
IconQrcode, IconQrcode,
IconShieldCheck, IconShieldCheck,
@@ -0,0 +1,88 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
</Group>
);
}
function DisablePublicSharingToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
const updatedWorkspace = await updateWorkspace({
disablePublicSharing: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
)
: t(
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
);
}
@@ -10,23 +10,18 @@ export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <Group justify="space-between" wrap="nowrap" gap="xl">
<Title order={4} my="sm"> <div>
MFA <Text size="md">{t("Enforce two-factor authentication")}</Text>
</Title> <Text size="sm" c="dimmed">
<Group justify="space-between" wrap="nowrap" gap="xl"> {t(
<div> "Once enforced, all members must enable two-factor authentication to access the workspace.",
<Text size="md">{t("Enforce two-factor authentication")}</Text> )}
<Text size="sm" c="dimmed"> </Text>
{t( </div>
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle /> <EnforceMfaToggle />
</Group> </Group>
</>
); );
} }
@@ -0,0 +1,84 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
};
export default function SpacePublicSharingToggle({
space,
}: SpacePublicSharingToggleProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const applyChange = async (value: boolean) => {
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
disablePublicSharing: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
)
: t(
"Are you sure you want to enable public sharing for this space?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{workspaceDisabled
? t("Public sharing is disabled at the workspace level")
: t("Prevent pages in this space from being shared publicly.")}
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
</Group>
);
}
+26 -10
View File
@@ -9,15 +9,16 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense(); const hasEnterpriseAccess = useEnterpriseAccess();
const { isBusiness } = usePlan(); const isCloudEE = useIsCloudEE();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -30,26 +31,41 @@ export default function Security() {
</Helmet> </Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<EnforceMfa /> <EnforceMfa />
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? ( {hasEnterpriseAccess && (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
</> </>
) : null} )}
<SsoProviderList /> <SsoProviderList />
</> </>
@@ -84,9 +84,14 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => { useEffect(() => {
commentEditor.commands.setContent(defaultContent); if (!editable && commentEditor && defaultContent) {
}, [defaultContent]); commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
@@ -1,5 +1,5 @@
import { Group, Text, Box, Badge } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
@@ -40,6 +40,7 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
@@ -56,9 +57,13 @@ function CommentListItem({
setIsLoading(true); setIsLoading(true);
const commentToUpdate = { const commentToUpdate = {
commentId: comment.id, commentId: comment.id,
content: JSON.stringify(content), content: JSON.stringify(editContentRef.current ?? content),
}; };
await updateCommentMutation.mutateAsync(commentToUpdate); await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false); setIsEditing(false);
emit({ emit({
@@ -128,6 +133,7 @@ function CommentListItem({
setIsEditing(true); setIsEditing(true);
} }
function cancelEdit() { function cancelEdit() {
editContentRef.current = null;
setIsEditing(false); setIsEditing(false);
} }
@@ -194,7 +200,7 @@ function CommentListItem({
<CommentEditor <CommentEditor
defaultContent={content} defaultContent={content}
editable={true} editable={true}
onUpdate={(newContent: any) => setContent(newContent)} onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onSave={handleUpdateComment} onSave={handleUpdateComment}
autofocus={true} autofocus={true}
/> />
@@ -1,5 +1,6 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core"; import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react"; import { IconCheck, IconCopy } from "@tabler/icons-react";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -8,7 +8,7 @@
} }
.mantine-AppShell-main { .mantine-AppShell-main {
padding-top: 0 !important; padding: 0 !important;
min-height: auto !important; min-height: auto !important;
} }
@@ -157,8 +157,10 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
titleEditor?.commands.focus("end"); // guard against Cannot access view['hasFocus'] error
}, 500); if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, [titleEditor]); }, [titleEditor]);
useEffect(() => { useEffect(() => {
@@ -1,38 +0,0 @@
.diffSummary {
border: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: rem(10px);
padding: rem(12px);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
:global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
:global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
:global(.history-diff-node-added) {
outline: rem(2px) solid light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
:global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
@@ -16,6 +16,36 @@
:global(.ProseMirror) { :global(.ProseMirror) {
padding: 0 !important; padding: 0 !important;
} }
& :global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
& :global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
& :global(.history-diff-node-added) {
outline: rem(2px) solid
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
& :global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
} }
.active { .active {
@@ -1,5 +1,4 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import "./css/history-diff.module.css";
import { useEffect } from "react"; import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
@@ -44,21 +43,21 @@ export function HistoryEditor({
if (previousContent) { if (previousContent) {
try { try {
const schema = editor.schema; const schema = editor.schema;
const docOld = Node.fromJSON(schema, previousContent); const oldContent = Node.fromJSON(schema, previousContent);
const docNew = Node.fromJSON(schema, content); const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(docOld, docNew, { const tr = recreateTransform(oldContent, newContent, {
complexSteps: true, complexSteps: false,
wordDiffs: true, wordDiffs: true,
simplifyDiff: true, simplifyDiff: true,
}); });
const changeSet = ChangeSet.create(docOld).addSteps( const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc, tr.doc,
tr.mapping.maps, tr.mapping.maps,
[], [],
); );
const changes = simplifyChanges(changeSet.changes, docNew); const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content); editor.commands.setContent(content);
@@ -84,7 +83,7 @@ export function HistoryEditor({
changeIndex++; changeIndex++;
const currentIndex = changeIndex; const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null; let foundSpecialNode: { node: Node; pos: number } | null = null;
docNew.nodesBetween(change.fromB, change.toB, (node, pos) => { newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) { if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize; const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) { if (change.fromB <= pos && change.toB >= nodeEnd) {
@@ -117,7 +116,7 @@ export function HistoryEditor({
changeIndex++; changeIndex++;
const currentIndex = changeIndex; const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null; let foundDeletedNode: { node: Node; pos: number } | null = null;
docOld.nodesBetween(change.fromA, change.toA, (node, pos) => { oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) { if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize; const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) { if (change.fromA <= pos && change.toA >= nodeEnd) {
@@ -140,7 +139,7 @@ export function HistoryEditor({
}), }),
); );
} else { } else {
const deletedText = docOld.textBetween( const deletedText = oldContent.textBetween(
change.fromA, change.fromA,
change.toA, change.toA,
"", "",
@@ -161,7 +160,7 @@ export function HistoryEditor({
} }
} }
decorationSet = DecorationSet.create(docNew, decorations); decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) { } catch (e) {
console.error("History diff failed:", e); console.error("History diff failed:", e);
editor.commands.setContent(content); editor.commands.setContent(content);
@@ -1,4 +1,4 @@
import { Text, Group, UnstyledButton } from "@mantine/core"; import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
@@ -6,6 +6,8 @@ import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react"; import { memo, useCallback } from "react";
const MAX_VISIBLE_AVATARS = 5;
interface HistoryItemProps { interface HistoryItemProps {
historyItem: IPageHistory; historyItem: IPageHistory;
index: number; index: number;
@@ -31,6 +33,9 @@ const HistoryItem = memo(function HistoryItem({
onHover?.(historyItem.id, index); onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]); }, [onHover, historyItem.id, index]);
const contributors = historyItem.contributors;
const hasContributors = contributors && contributors.length > 0;
return ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
@@ -39,25 +44,54 @@ const HistoryItem = memo(function HistoryItem({
onMouseLeave={onHoverEnd} onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
<div>
<Text size="sm">
{formattedDate(new Date(historyItem.createdAt))}
</Text>
<div style={{ flex: 1 }}> <Group gap={6} wrap="nowrap" mt={4}>
<Group gap={4} wrap="nowrap"> {hasContributors ? (
<CustomAvatar <>
size="sm" <Tooltip.Group openDelay={300} closeDelay={100}>
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl} <Avatar.Group spacing={8}>
name={historyItem.lastUpdatedBy?.name} {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
/> <Tooltip key={contributor.id} label={contributor.name} withArrow>
<CustomAvatar
size="sm"
avatarUrl={contributor.avatarUrl}
name={contributor.name}
/>
</Tooltip>
))}
{contributors.length > MAX_VISIBLE_AVATARS && (
<Tooltip
withArrow
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
<div key={c.id}>{c.name}</div>
))}
>
<Avatar size="sm" color="gray">
+{contributors.length - MAX_VISIBLE_AVATARS}
</Avatar>
</Tooltip>
)}
</Avatar.Group>
</Tooltip.Group>
{contributors.length === 1 && (
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name} {contributors[0].name}
</Text> </Text>
</Group> )}
</div> </>
</div> ) : (
<>
<CustomAvatar
size="sm"
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy?.name}
/>
<Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy?.name}
</Text>
</>
)}
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
@@ -157,7 +157,7 @@ function HistoryList({ pageId }: Props) {
size="compact-md" size="compact-md"
onClick={() => setHistoryModalOpen(false)} onClick={() => setHistoryModalOpen(false)}
> >
{t("Close")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}> <Button size="compact-md" onClick={confirmRestore}>
{t("Restore")} {t("Restore")}
@@ -62,11 +62,18 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const selectData = useMemo( const selectData = useMemo(
() => () =>
historyItems.map((item) => ({ historyItems.map((item) => {
value: item.id, const contributors = item.contributors;
label: formattedDate(new Date(item.createdAt)), const hasContributors = contributors && contributors.length > 0;
userName: item.lastUpdatedBy?.name, const names = hasContributors
})), ? contributors.map((c) => c.name).join(", ")
: item.lastUpdatedBy?.name;
return {
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: names,
};
}),
[historyItems], [historyItems],
); );
@@ -157,7 +164,7 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
{canRestore && ( {canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm"> <Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}> <Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Close")} {t("Cancel")}
</Button> </Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button> <Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group> </Group>
@@ -18,4 +18,5 @@ export interface IPageHistory {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastUpdatedBy: IPageHistoryUser; lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
} }
@@ -17,7 +17,8 @@ import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks"; import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -11,6 +11,7 @@ import {
IconBrandNotion, IconBrandNotion,
IconCheck, IconCheck,
IconFileCode, IconFileCode,
IconFileTypeDocx,
IconFileTypeZip, IconFileTypeZip,
IconMarkdown, IconMarkdown,
IconX, IconX,
@@ -86,11 +87,13 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null); const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null); const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null); const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -265,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
// Reset file inputs after successful upload // Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current(); if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current(); if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current();
const pageCountText = const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -321,6 +325,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </FileButton>
<FileButton
onChange={handleFileUpload}
accept=".docx"
multiple
resetRef={docxFileRef}
>
{(props) => (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseDocx}
>
<Button
disabled={!canUseDocx}
justify="start"
variant="default"
leftSection={<IconFileTypeDocx size={18} />}
{...props}
>
Word (DOCX)
</Button>
</Tooltip>
)}
</FileButton>
<FileButton <FileButton
onChange={(file) => handleZipUpload(file, "notion")} onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip" accept="application/zip"
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { import {
useClipboard,
useDisclosure, useDisclosure,
useElementSize, useElementSize,
useMergedRef, useMergedRef,
} from "@mantine/hooks"; } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { dfs } from "react-arborist/dist/module/utils"; import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -13,7 +13,7 @@ import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
} from "@/features/page/page.utils.ts"; } from "@/features/page/page.utils.ts";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts"; import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
@@ -26,6 +26,9 @@ import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -40,6 +43,12 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled =
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@@ -164,6 +173,20 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{t("Upgrade Plan")} {t("Upgrade Plan")}
</Button> </Button>
</> </>
) : sharingDisabled ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</>
) : isDescendantShared ? ( ) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
@@ -18,6 +18,8 @@ import {
ResponsiveSettingsControl, ResponsiveSettingsControl,
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -26,6 +28,8 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const hasEnterpriseAccess = useEnterpriseAccess();
const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false); const [isIconUploading, setIsIconUploading] = useState(false);
@@ -77,7 +81,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
fallbackName={space.name} fallbackName={space.name}
size={"60px"} size={"60px"}
variant="filled" variant="filled"
type={AvatarIconType.SPACE_ICON} type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload} onUpload={handleIconUpload}
onRemove={handleIconRemove} onRemove={handleIconRemove}
@@ -88,6 +91,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
@@ -5,6 +5,14 @@ import {
} from "@/features/space/permissions/permissions.type.ts"; } from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts"; import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpaceSharingSettings {
disabled?: boolean;
}
export interface ISpaceSettings {
sharing?: ISpaceSharingSettings;
}
export interface ISpace { export interface ISpace {
id: string; id: string;
name: string; name: string;
@@ -18,6 +26,9 @@ export interface ISpace {
memberCount?: number; memberCount?: number;
spaceId?: string; spaceId?: string;
membership?: IMembership; membership?: IMembership;
settings?: ISpaceSettings;
// for updates
disablePublicSharing?: boolean;
} }
interface IMembership { interface IMembership {
@@ -8,7 +8,7 @@ import {
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@/hooks/use-clipboard";
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts"; import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -1,7 +1,8 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core"; import { Button, Group, Text, TextInput } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function WorkspaceInviteSection() { export default function WorkspaceInviteSection() {
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
await api.post("/workspace/members/delete", data); await api.post("/workspace/members/delete", data);
} }
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) { export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;
} }
@@ -66,7 +66,9 @@ export async function createInvitation(data: ICreateInvite) {
return req.data; return req.data;
} }
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> { export async function acceptInvitation(
data: IAcceptInvite,
): Promise<{ requiresLogin?: boolean }> {
const req = await api.post("/workspace/invites/accept", data); const req = await api.post("/workspace/invites/accept", data);
return req.data; return req.data;
} }
@@ -108,4 +110,3 @@ export async function getAppVersion(): Promise<IVersion> {
const req = await api.post("/version"); const req = await api.post("/version");
return req.data; return req.data;
} }
@@ -22,16 +22,23 @@ export interface IWorkspace {
plan?: string; plan?: string;
hasLicenseKey?: boolean; hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean;
disablePublicSharing?: boolean;
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings; ai?: IWorkspaceAiSettings;
sharing?: IWorkspaceSharingSettings;
} }
export interface IWorkspaceAiSettings { export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
} }
export interface IWorkspaceSharingSettings {
disabled?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
+60
View File
@@ -0,0 +1,60 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
// polyfilled to support execCommand fallback
import { useState } from "react";
import { execCommandCopy } from "@docmost/editor-ext";
export type UseClipboardOptions = {
timeout?: number;
};
export type UseClipboardReturnValue = {
copy: (value: string) => void;
reset: () => void;
error: Error | null;
copied: boolean;
};
export function useClipboard(
options: UseClipboardOptions = { timeout: 2000 },
): UseClipboardReturnValue {
const [error, setError] = useState<Error | null>(null);
const [copied, setCopied] = useState(false);
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
const handleCopyResult = (value: boolean) => {
window.clearTimeout(copyTimeout!);
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
setCopied(value);
};
const copy = (value: string) => {
if ("clipboard" in navigator) {
navigator.clipboard
.writeText(value)
.then(() => handleCopyResult(true))
.catch(() => {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
});
} else {
try {
execCommandCopy(value);
handleCopyResult(true);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to copy"));
}
}
};
const reset = () => {
setCopied(false);
setError(null);
window.clearTimeout(copyTimeout!);
};
return { copy, reset, error, copied };
}
+23 -23
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.25.0-beta.1", "version": "0.25.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -33,31 +33,31 @@
"@ai-sdk/google": "^3.0.9", "@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.982.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^8.3.0", "@fastify/static": "^9.0.0",
"@langchain/core": "1.1.13", "@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11", "@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11", "@nestjs/core": "^11.1.13",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11", "@nestjs/platform-fastify": "^11.1.13",
"@nestjs/platform-socket.io": "^11.1.11", "@nestjs/platform-socket.io": "^11.1.13",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11", "@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "1.0.7",
"@react-email/render": "1.0.2", "@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37", "ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1", "ai-sdk-ollama": "^3.1.1",
@@ -92,9 +92,9 @@
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -111,32 +111,32 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.20.0", "@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.4", "@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1", "@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10", "@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16", "@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.3",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"eslint": "^9.20.1", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"jest": "^29.7.0", "jest": "^30.2.0",
"kysely-codegen": "^0.19.0", "kysely-codegen": "^0.19.0",
"prettier": "^3.5.1", "prettier": "^3.5.1",
"react-email": "3.0.2", "react-email": "5.2.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.4.6",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
@@ -1,5 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server'; import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
export type CollabEventHandlers = ReturnType< export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers'] CollaborationHandler['getHandlers']
@@ -20,6 +27,44 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default'); // const fragment = doc.getXmlFragment('default');
//}); //});
}, },
updatePageContent: async (
documentName: string,
payload: {
prosemirrorJson: any;
operation: string;
user: User;
},
) => {
const { prosemirrorJson, operation, user } = payload;
this.logger.debug('Updating page content via yjs', documentName);
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
if (operation === 'replace') {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const newDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
);
},
}; };
} }
@@ -1,4 +1,10 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import {
Global,
Logger,
Module,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
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 { CollaborationGateway } from './collaboration.gateway'; import { CollaborationGateway } from './collaboration.gateway';
@@ -7,9 +13,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import { WebSocket } from 'ws'; 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 { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler'; import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
@Module({ @Module({
providers: [ providers: [
@@ -17,7 +24,8 @@ import { CollaborationHandler } from './collaboration.handler';
AuthenticationExtension, AuthenticationExtension,
PersistenceExtension, PersistenceExtension,
LoggerExtension, LoggerExtension,
HistoryListener, HistoryProcessor,
CollabHistoryService,
CollaborationHandler, CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
@@ -34,6 +34,7 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
addUniqueIdsToDoc, addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -42,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; //import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model'; import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
@@ -161,3 +163,37 @@ function stripUnknownNodes(
return json; return json;
} }
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
if (node.type === 'text') {
const ytext = new Y.XmlText();
ytext.insert(0, node.text || '');
if (node.marks?.length > 0) {
const attrs: Record<string, any> = {};
for (const mark of node.marks) {
attrs[mark.type] = mark.attrs || true;
}
ytext.format(0, node.text?.length || 0, attrs);
}
return ytext;
}
const element = new Y.XmlElement(node.type);
if (node.attrs) {
for (const [key, value] of Object.entries(node.attrs)) {
if (value !== null && value !== undefined) {
element.setAttribute(key, value as any);
}
}
}
if (node.content?.length > 0) {
const children = node.content.map(prosemirrorNodeToYElement);
element.insert(0, children);
}
return element;
}
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return htmlToMarkdown(html);
}
@@ -0,0 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
@@ -22,8 +21,17 @@ import {
extractPageMentions, extractPageMentions,
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface'; import {
IPageBacklinkJob,
IPageHistoryJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable() @Injectable()
export class PersistenceExtension implements Extension { export class PersistenceExtension implements Extension {
@@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension {
constructor( constructor(
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension {
} }
let page: Page = null; let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try { try {
await executeTx(this.db, async (trx) => { await executeTx(this.db, async (trx) => {
@@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined; let contributorIds = undefined;
try { try {
const existingContributors = page.contributorIds || []; const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from( contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]), new Set([...existingContributors, ...editingUserIds, page.creatorId]),
); );
this.contributors.delete(documentName);
} catch (err) { } catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']); //this.logger.debug('Contributors error:' + err?.['message']);
} }
@@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension {
} }
if (page) { if (page) {
this.eventEmitter.emit('collab.page.updated', { await this.collabHistory.addContributors(pageId, editingUserIds);
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
const mentions = extractMentions(tiptapJson); const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions); const pageMentions = extractPageMentions(mentions);
@@ -174,12 +174,15 @@ export class PersistenceExtension implements Extension {
pageIds: [pageId], pageIds: [pageId],
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
}); });
await this.enqueuePageHistory(page);
} }
} }
async onChange(data: onChangePayload) { async onChange(data: onChangePayload) {
const documentName = data.documentName; const documentName = data.documentName;
const userId = data.context?.user.id; const userId = data.context?.user?.id;
if (!userId) return; if (!userId) return;
if (!this.contributors.has(documentName)) { if (!this.contributors.has(documentName)) {
@@ -193,4 +196,26 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName; const documentName = data.documentName;
this.contributors.delete(documentName); this.contributors.delete(documentName);
} }
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
} }
@@ -1,50 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = this.environmentService.isDevelopment()
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
) {
try {
await this.pageHistoryRepo.saveHistory(page);
this.logger.debug(`New history created for: ${page.id}`);
} catch (err) {
this.logger.error(`Failed to create history for page: ${page.id}`, err);
}
}
}
}
@@ -0,0 +1,84 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -9,6 +9,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module'; import { HealthModule } from '../../integrations/health/health.module';
import { CollaborationController } from './collaboration.controller'; import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module'; import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
@Module({ @Module({
imports: [ imports: [
@@ -19,6 +21,9 @@ import { LoggerModule } from '../../common/logger/logger.module';
QueueModule, QueueModule,
HealthModule, HealthModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
], ],
controllers: [ controllers: [
AppController, AppController,
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
+177
View File
@@ -0,0 +1,177 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
import { tiptapExtensions } from './collaboration.util';
export type YjsSelection = {
anchor: any;
head: any;
};
export function setYjsMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: YjsSelection,
markName: string,
markAttributes: Record<string, any>,
) {
const schema = getSchema(tiptapExtensions);
const { mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
if (anchor === null || head === null) {
throw new Error(
'Could not resolve Y.js relative positions to absolute positions',
);
}
const from = Math.min(anchor, head);
const to = Math.max(anchor, head);
// Apply mark directly to Y.js XmlText nodes
// This bypasses updateYFragment which has compatibility issues
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
}
function applyMarkToYFragment(
fragment: Y.XmlFragment,
from: number,
to: number,
markName: string,
markAttributes: Record<string, any>,
) {
let pos = 0;
const processItem = (item: any): boolean => {
if (pos >= to) return false;
if (item instanceof Y.XmlText) {
const textLength = item.length;
const itemEnd = pos + textLength;
if (itemEnd > from && pos < to) {
const formatFrom = Math.max(0, from - pos);
const formatTo = Math.min(textLength, to - pos);
const formatLength = formatTo - formatFrom;
if (formatLength > 0) {
item.format(formatFrom, formatLength, { [markName]: markAttributes });
}
}
pos = itemEnd;
} else if (item instanceof Y.XmlElement) {
pos++; // Opening tag
for (let i = 0; i < item.length; i++) {
if (!processItem(item.get(i))) return false;
}
pos++; // Closing tag
}
return true;
};
for (let i = 0; i < fragment.length; i++) {
if (!processItem(fragment.get(i))) break;
}
}
/**
* Removes a mark from all text in the fragment that has the specified attribute value.
* Useful for deleting comments by commentId.
*/
export function removeYjsMarkByAttribute(
fragment: Y.XmlFragment,
markName: string,
attributeName: string,
attributeValue: string,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
// Get all formatting deltas to find ranges with this mark
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[attributeName] === attributeValue) {
// Remove the mark by setting it to null
item.format(offset, length, { [markName]: null });
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
*/
export function updateYjsMarkAttribute(
fragment: Y.XmlFragment,
markName: string,
findByAttribute: { name: string; value: string },
newAttributes: Record<string, any>,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (
markAttr &&
markAttr[findByAttribute.name] === findByAttribute.value
) {
// Update the mark with new attributes (merge with existing)
item.format(offset, length, {
[markName]: { ...markAttr, ...newAttributes },
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
@@ -17,13 +17,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import { StorageService } from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -181,22 +182,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'private');
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -205,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName') @Get('/files/public/:fileId/:fileName')
async getPublicFile( async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@@ -243,22 +230,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'public');
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -433,4 +405,70 @@ export class AttachmentController {
return; return;
} }
} }
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
} }
@@ -99,6 +99,7 @@ export class AttachmentService {
if (isUpdate) { if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment( attachment = await this.attachmentRepo.updateAttachment(
{ {
fileSize: preparedFile.fileSize,
updatedAt: new Date(), updatedAt: new Date(),
}, },
attachmentId, attachmentId,
@@ -1,4 +1,13 @@
import { IsOptional, IsString, IsUUID } from 'class-validator'; import {
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentFormat = 'json' | 'markdown' | 'html';
export class CreatePageDto { export class CreatePageDto {
@IsOptional() @IsOptional()
@@ -15,4 +24,12 @@ export class CreatePageDto {
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
@@ -1,10 +1,14 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
import { Transform } from 'class-transformer';
import { ContentFormat } from './create-page.dto';
export class PageIdDto { export class PageIdDto {
@IsString() @IsString()
@@ -30,6 +34,11 @@ export class PageInfoDto extends PageIdDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeContent: boolean; includeContent: boolean;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
export class DeletePageDto extends PageIdDto { export class DeletePageDto extends PageIdDto {
@@ -1,8 +1,24 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto'; import { CreatePageDto, ContentFormat } from './create-page.dto';
import { IsString } from 'class-validator'; import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentOperation = 'append' | 'prepend' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString() @IsString()
pageId: string; pageId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['append', 'prepend', 'replace'])
operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
+53 -3
View File
@@ -35,6 +35,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -66,6 +70,17 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
dto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
...page,
content: contentOutput,
};
}
return page; return page;
} }
@@ -84,7 +99,25 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.create(user.id, workspace.id, createPageDto); const page = await this.pageService.create(
user.id,
workspace.id,
createPageDto,
);
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
page.content
) {
const contentOutput =
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
}
return page;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -101,7 +134,25 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.update(page, updatePageDto, user.id); const updatedPage = await this.pageService.update(
page,
updatePageDto,
user,
);
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
updatedPage.content
) {
const contentOutput =
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
}
return updatedPage;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -215,7 +266,6 @@ export class PageController {
} }
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
+2 -1
View File
@@ -4,11 +4,12 @@ import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service'; import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({ @Module({
controllers: [PageController], controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule], imports: [StorageModule, CollaborationModule],
}) })
export class PageModule {} export class PageModule {}
@@ -9,7 +9,9 @@ export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {} constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> { async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId); return await this.pageHistoryRepo.findById(historyId, {
includeContent: true,
});
} }
async findHistoryByPageId( async findHistoryByPageId(
@@ -4,8 +4,8 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto'; import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto'; import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -28,7 +28,11 @@ import {
isAttachmentNode, isAttachmentNode,
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } from '../../../common/helpers/prosemirror/utils';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; import {
htmlToJson,
jsonToNode,
jsonToText,
} from 'src/collaboration/collaboration.util';
import { import {
CopyPageMapEntry, CopyPageMapEntry,
ICopyPageAttachment, ICopyPageAttachment,
@@ -40,6 +44,8 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
@Injectable() @Injectable()
export class PageService { export class PageService {
@@ -53,6 +59,7 @@ export class PageService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
) {} ) {}
async findById( async findById(
@@ -88,7 +95,22 @@ export class PageService {
parentPageId = parentPage.id; parentPageId = parentPage.id;
} }
const createdPage = await this.pageRepo.insertPage({ let content = undefined;
let textContent = undefined;
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.format) {
const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content,
createPageDto.format,
);
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson);
}
return this.pageRepo.insertPage({
slugId: generateSlugId(), slugId: generateSlugId(),
title: createPageDto.title, title: createPageDto.title,
position: await this.nextPagePosition( position: await this.nextPagePosition(
@@ -101,9 +123,10 @@ export class PageService {
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
lastUpdatedById: userId, lastUpdatedById: userId,
content,
textContent,
ydoc,
}); });
return createdPage;
} }
async nextPagePosition(spaceId: string, parentPageId?: string) { async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -150,23 +173,37 @@ export class PageService {
async update( async update(
page: Page, page: Page,
updatePageDto: UpdatePageDto, updatePageDto: UpdatePageDto,
userId: string, user: User,
): Promise<Page> { ): Promise<Page> {
const contributors = new Set<string>(page.contributorIds); const contributors = new Set<string>(page.contributorIds);
contributors.add(userId); contributors.add(user.id);
const contributorIds = Array.from(contributors); const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ {
title: updatePageDto.title, title: updatePageDto.title,
icon: updatePageDto.icon, icon: updatePageDto.icon,
lastUpdatedById: userId, lastUpdatedById: user.id,
updatedAt: new Date(), updatedAt: new Date(),
contributorIds: contributorIds, contributorIds: contributorIds,
}, },
page.id, page.id,
); );
if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.format
) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.operation,
updatePageDto.format,
user,
);
}
return await this.pageRepo.findById(page.id, { return await this.pageRepo.findById(page.id, {
includeSpace: true, includeSpace: true,
includeContent: true, includeContent: true,
@@ -176,6 +213,23 @@ export class PageService {
}); });
} }
async updatePageContent(
pageId: string,
content: string | object,
operation: ContentOperation,
format: ContentFormat,
user: User,
): Promise<void> {
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
const documentName = `page.${pageId}`;
await this.collaborationGateway.handleYjsEvent(
'updatePageContent',
documentName,
{ operation, prosemirrorJson, user },
);
}
async getSidebarPages( async getSidebarPages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -209,7 +263,11 @@ export class PageService {
cursor: pagination.cursor, cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor, beforeCursor: pagination.beforeCursor,
fields: [ fields: [
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() }, {
expression: 'position',
direction: 'asc',
orderModifier: (ob) => ob.collate('C').asc(),
},
{ expression: 'id', direction: 'asc' }, { expression: 'id', direction: 'asc' },
], ],
parseCursor: (cursor) => ({ parseCursor: (cursor) => ({
@@ -653,4 +711,36 @@ export class PageService {
): Promise<void> { ): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId); await this.pageRepo.removePage(pageId, userId, workspaceId);
} }
private async parseProsemirrorContent(
content: string | object,
format: ContentFormat,
): Promise<any> {
let prosemirrorJson: any;
switch (format) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
return prosemirrorJson;
}
} }
+41 -2
View File
@@ -64,8 +64,18 @@ export class ShareController {
throw new BadRequestException(); throw new BadRequestException();
} }
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return { return {
...(await this.shareService.getSharedPage(dto, workspace.id)), ...shareData,
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -86,6 +96,14 @@ export class ShareController {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share; return share;
} }
@@ -127,6 +145,14 @@ export class ShareController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({ return this.shareService.createShare({
page, page,
authUserId: user.id, authUserId: user.id,
@@ -176,8 +202,21 @@ export class ShareController {
@Body() dto: ShareIdDto, @Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return { return {
...(await this.shareService.getShareTree(dto.shareId, workspace.id)), ...treeData,
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -264,6 +264,31 @@ export class ShareService {
return ancestor; return ancestor;
} }
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> { async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content); const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson); const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,10 +1,14 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto'; import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
} }
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -17,12 +18,18 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {
constructor( constructor(
private spaceRepo: SpaceRepo, private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService, private spaceMemberService: SpaceMemberService,
private shareRepo: ShareRepo,
private workspaceRepo: WorkspaceRepo,
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {} ) {}
@@ -105,6 +112,31 @@ export class SpaceService {
} }
} }
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace( return await this.spaceRepo.updateSpace(
{ {
name: updateSpaceDto.name, name: updateSpaceDto.name,
@@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
generativeAi: boolean; generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
} }
@@ -5,6 +5,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service'; import { SpaceService } from '../../space/services/space.service';
@@ -33,6 +34,7 @@ import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -47,6 +49,8 @@ export class WorkspaceService {
private userRepo: UserRepo, private userRepo: UserRepo,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private domainService: DomainService, private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -358,6 +362,32 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.generativeAi;
} }
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -0,0 +1,9 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').dropColumn('settings').execute();
}
@@ -0,0 +1,15 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -9,24 +9,43 @@ import {
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
@Injectable() @Injectable()
export class PageHistoryRepo { export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'createdAt',
];
async findById( async findById(
pageHistoryId: string, pageHistoryId: string,
trx?: KyselyTransaction, opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> { ): Promise<PageHistory> {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, opts?.trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId) .where('id', '=', pageHistoryId)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -43,7 +62,10 @@ export class PageHistoryRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> { async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory( await this.insertPageHistory(
{ {
pageId: page.id, pageId: page.id,
@@ -53,18 +75,20 @@ export class PageHistoryRepo {
icon: page.icon, icon: page.icon,
coverPhoto: page.coverPhoto, coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId, lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
}, },
trx, opts?.trx,
); );
} }
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) { async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId); .where('pageId', '=', pageId);
return executeWithCursorPagination(query, { return executeWithCursorPagination(query, {
@@ -76,12 +100,19 @@ export class PageHistoryRepo {
}); });
} }
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) { async findPageLastHistory(
const db = dbOrTx(this.db, trx); pageId: string,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId) .where('pageId', '=', pageId)
.limit(1) .limit(1)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
@@ -96,4 +127,17 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'), .whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy'); ).as('lastUpdatedBy');
} }
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
} }
@@ -136,6 +136,20 @@ export class ShareRepo {
await query.execute(); await query.execute();
} }
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
}
async getShares(userId: string, pagination: PaginationOptions) { async getShares(userId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('shares') .selectFrom('shares')
@@ -89,6 +89,26 @@ export class SpaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async updateSharingSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace( async insertSpace(
insertableSpace: InsertableSpace, insertableSpace: InsertableSpace,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
.updateTable('workspaces') .updateTable('workspaces')
.set({ .set({
settings: sql`COALESCE(settings, '{}'::jsonb) settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb) || jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -185,7 +185,25 @@ export class WorkspaceRepo {
.updateTable('workspaces') .updateTable('workspaces')
.set({ .set({
settings: sql`COALESCE(settings, '{}'::jsonb) settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb) || jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(), updatedAt: new Date(),
}) })
+2
View File
@@ -199,6 +199,7 @@ export interface GroupUsers {
export interface PageHistory { export interface PageHistory {
content: Json | null; content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null; coverPhoto: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
icon: string | null; icon: string | null;
@@ -273,6 +274,7 @@ export interface Spaces {
id: Generated<string>; id: Generated<string>;
logo: string | null; logo: string | null;
name: string | null; name: string | null;
settings: Json | null;
slug: string; slug: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
visibility: Generated<string>; visibility: Generated<string>;
@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation'; import { validate } from './environment.validation';
import { envPath } from '../../common/helpers'; import { envPath } from '../../common/helpers';
import { DomainService } from './domain.service'; import { DomainService } from './domain.service';
import { LicenseCheckService } from './license-check.service';
@Global() @Global()
@Module({ @Module({
@@ -15,7 +16,7 @@ import { DomainService } from './domain.service';
validate, validate,
}), }),
], ],
providers: [EnvironmentService, DomainService], providers: [EnvironmentService, DomainService, LicenseCheckService],
exports: [EnvironmentService, DomainService], exports: [EnvironmentService, DomainService, LicenseCheckService],
}) })
export class EnvironmentModule {} export class EnvironmentModule {}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
@Injectable()
export class LicenseCheckService {
constructor(
private moduleRef: ModuleRef,
private environmentService: EnvironmentService,
) {}
isValidEELicense(licenseKey: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.isValidEELicense(licenseKey);
} catch {
return false;
}
}
}
@@ -44,7 +44,7 @@ export class ImportController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const validFileExtensions = ['.md', '.html']; const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb'); const maxFileSize = bytes('10mb');
@@ -29,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants'; import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
@@ -40,6 +41,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE) @InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue, private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
) {} ) {}
async importPage( async importPage(
@@ -59,11 +61,22 @@ export class ImportService {
let prosemirrorState = null; let prosemirrorState = null;
let createdPage = null; let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
try { try {
if (fileExtension.endsWith('.md')) { if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent); prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) { } else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent); prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
} }
} catch (err) { } catch (err) {
const message = 'Error processing file content'; const message = 'Error processing file content';
@@ -87,6 +100,7 @@ export class ImportService {
const pagePosition = await this.getNewPagePosition(spaceId); const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({ createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(), slugId: generateSlugId(),
title: pageTitle, title: pageTitle,
content: prosemirrorJson, content: prosemirrorJson,
@@ -129,6 +143,42 @@ export class ImportService {
} }
} }
async processDocx(
fileBuffer: Buffer,
workspaceId: string,
spaceId: string,
pageId: string,
userId: string,
): Promise<any> {
let DocxImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
} catch (err) {
this.logger.error(
'DOCX import requested but EE module not bundled in this build',
);
throw new BadRequestException(
'This feature requires a valid enterprise license.',
);
}
const docxImportService = this.moduleRef.get(
DocxImportModule.DocxImportService,
{ strict: false },
);
const html = await docxImportService.convertDocxToHtml(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
return this.processHTML(html);
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> { async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) { if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`); // this.logger.debug(`Converting prosemirror json state to ydoc`);
@@ -6,6 +6,7 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}', FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}', SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}', AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
} }
export enum QueueJob { export enum QueueJob {
@@ -58,4 +59,6 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
} }
@@ -9,4 +9,8 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob { export interface IStripeSeatsSyncJob {
workspaceId: string; workspaceId: string;
}
export interface IPageHistoryJob {
pageId: string;
} }
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1, attempts: 1,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
], ],
exports: [BullModule], exports: [BullModule],
providers: [BacklinksProcessor], providers: [BacklinksProcessor],
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
return await fs.pathExists(this._fullPath(filePath)); return await fs.pathExists(this._fullPath(filePath));
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers'; import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage'; import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver { export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client; private readonly s3Client: S3Client;
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
await upload.done(); await upload.done();
} catch (err) { } catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} }
} }
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done(); await upload.done();
} catch (err) { } catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally { } finally {
if (shouldDestroyClient && clientToUse) { if (shouldDestroyClient && clientToUse) {
@@ -127,6 +130,25 @@ export class S3Driver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Range: `bytes=${range.start}-${range.end}`,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (err) {
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
const command = new HeadObjectCommand({ const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>; readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>; exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string; getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath); return this.storageDriver.readStream(filePath);
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath); return this.storageDriver.exists(filePath);
} }
+5 -4
View File
@@ -7,7 +7,7 @@ services:
environment: environment:
APP_URL: 'http://localhost:3000' APP_URL: 'http://localhost:3000'
APP_SECRET: 'REPLACE_WITH_LONG_SECRET' APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost?schema=public' DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
REDIS_URL: 'redis://redis:6379' REDIS_URL: 'redis://redis:6379'
ports: ports:
- "3000:3000" - "3000:3000"
@@ -16,17 +16,18 @@ services:
- docmost:/app/data/storage - docmost:/app/data/storage
db: db:
image: postgres:16-alpine image: postgres:18
environment: environment:
POSTGRES_DB: docmost POSTGRES_DB: docmost
POSTGRES_USER: docmost POSTGRES_USER: docmost
POSTGRES_PASSWORD: STRONG_DB_PASSWORD POSTGRES_PASSWORD: STRONG_DB_PASSWORD
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql
redis: redis:
image: redis:7.2-alpine image: redis:8
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redis_data:/data - redis_data:/data
+25 -8
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.25.0-beta.1", "version": "0.25.3",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -23,9 +23,9 @@
"@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/provider": "3.4.3", "@hocuspocus/provider": "3.4.4",
"@hocuspocus/server": "3.4.3", "@hocuspocus/server": "3.4.4",
"@hocuspocus/transformer": "3.4.3", "@hocuspocus/transformer": "3.4.4",
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
@@ -56,12 +56,13 @@
"@tiptap/react": "3.17.1", "@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.17.1", "@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.17.1", "@tiptap/suggestion": "3.17.1",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"diff": "8.0.3", "diff": "8.0.3",
"dompurify": "^3.2.6", "dompurify": "^3.3.1",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0", "image-dimensions": "^2.5.0",
@@ -78,12 +79,12 @@
"yjs": "^13.6.29" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "20.4.5", "@nx/js": "22.5.0",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6", "@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": "22.5.0",
"tsx": "^4.19.3" "tsx": "^4.19.3"
}, },
"workspaces": { "workspaces": {
@@ -101,7 +102,23 @@
"jsdom": "25.0.1", "jsdom": "25.0.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1", "prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7" "y-prosemirror": "1.3.7",
"glob": "10.5.0",
"lodash": "4.17.23",
"ws": "8.19.0",
"cross-spawn": "7.0.5",
"dompurify": "3.3.1",
"tmp": "0.2.5",
"lodash-es": "4.17.23",
"@tiptap/core": "3.17.1",
"@tiptap/pm": "3.17.1",
"@tiptap/starter-kit": "3.17.1",
"@tiptap/extension-blockquote": "3.17.1",
"@tiptap/extension-bold": "3.17.0",
"@tiptap/extension-bubble-menu": "3.17.1",
"@tiptap/extension-bullet-list": "3.17.1",
"@tiptap/extension-list": "3.17.1",
"@tiptap/extension-code": "3.17.1"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
} }
@@ -1,145 +0,0 @@
# prosemirror-changeset
This is a helper module that can turn a sequence of document changes
into a set of insertions and deletions, for example to display them in
a change-tracking interface. Such a set can be built up incrementally,
in order to do such change tracking in a halfway performant way during
live editing.
This code is licensed under an [MIT
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
## Programming interface
Insertions and deletions are represented as spans’—ranges in the
document. The deleted spans refer to the original document, whereas
the inserted ones point into the current document.
It is possible to associate arbitrary data values with such spans, for
example to track the user that made the change, the timestamp at which
it was made, or the step data necessary to invert it again.
### class Change`<Data = any>`
A replaced range with metadata associated with it.
* **`fromA`**`: number`\
The start of the range deleted/replaced in the old document.
* **`toA`**`: number`\
The end of the range in the old document.
* **`fromB`**`: number`\
The start of the range inserted in the new document.
* **`toB`**`: number`\
The end of the range in the new document.
* **`deleted`**`: readonly Span[]`\
Data associated with the deleted content. The length of these
spans adds up to `this.toA - this.fromA`.
* **`inserted`**`: readonly Span[]`\
Data associated with the inserted content. Length adds up to
`this.toB - this.fromB`.
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
This merges two changesets (the end document of x should be the
start document of y) into a single one spanning the start of x to
the end of y.
### class Span`<Data = any>`
Stores metadata for a part of a change.
* **`length`**`: number`\
The length of this span.
* **`data`**`: Data`\
The data associated with this span.
### class ChangeSet`<Data = any>`
A change set tracks the changes to a document from a given point
in the past. It condenses a number of step maps down to a flat
sequence of replacements, and simplifies replacments that
partially undo themselves by comparing their content.
* **`changes`**`: readonly Change[]`\
Replaced regions.
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
Computes a new changeset by adding the given step maps and
metadata (either as an array, per-map, or as a single value to be
associated with all maps) to the current set. Will not mutate the
old set.
Note that due to simplification that happens after each add,
incrementally adding steps might create a different final set
than adding all those changes at once, since different document
tokens might be matched during simplification depending on the
boundaries of the current changed ranges.
* **`startDoc`**`: Node`\
The starting document of the change set.
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
Map the span's data values in the given set through a function
and construct a new set with the resulting data.
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
Compare two changesets and return the range in which they are
changed, if any. If the document changed between the maps, pass
the maps for the steps that changed it as second argument, and
make sure the method is called on the old set and passed the new
set. The returned positions will be in new document coordinates.
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
Create a changeset with the given base object and configuration.
The `combine` function is used to compare and combine metadata—it
should return null when metadata isn't compatible, and a combined
version for a merged range when it is.
When given, a token encoder determines how document tokens are
serialized and compared when diffing the content produced by
changes. The default is to just compare nodes by name and text
by character, ignoring marks and attributes.
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
Simplifies a set of changes for presentation. This makes the
assumption that having both insertions and deletions within a word
is confusing, and, when such changes occur without a word boundary
between them, they should be expanded to cover the entire set of
words (in the new document) they touch. An exception is made for
single-character replacements.
### interface TokenEncoder`<T>`
A token encoder can be passed when creating a `ChangeSet` in order
to influence the way the library runs its diffing algorithm. The
encoder determines how document tokens (such as nodes and
characters) are encoded and compared.
Note that both the encoding and the comparison may run a lot, and
doing non-trivial work in these functions could impact
performance.
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
Encode a given character, with the given marks applied.
* **`encodeNodeStart`**`(node: Node) → T`\
Encode the start of a node or, if this is a leaf node, the
entire node.
* **`encodeNodeEnd`**`(node: Node) → T`\
Encode the end token for the given node. It is valid to encode
every end token in the same way.
* **`compareTokens`**`(a: T, b: T) → boolean`\
Compare the given tokens. Should return true when they count as
equal.
@@ -1,30 +0,0 @@
# prosemirror-recreate-transform
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
This is a non-core module of [ProseMirror](http://prosemirror.net).
ProseMirror is a well-behaved rich semantic content editor based on
contentEditable, with support for collaborative editing and custom
document schemas.
Every change to the document is recorded by ProseMirror as a step.
This module allows recreating the steps needed to go from document
A to B should these not be available otherwise. Recreating steps
can be interesting for example in order to show the changes between
two document versions without having access to the original steps.
Recreating a `Transform` works this way:
```js
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
let tr = recreateTransform(
startDoc,
endDoc,
{
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
simplifyDiffs: true // Whether steps should be merged, where possible
}
);
```
@@ -4,11 +4,13 @@ import TiptapHeading, {
import { mergeAttributes } from "@tiptap/react"; import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state"; import { Plugin } from "prosemirror-state";
import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`; const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`; const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
// @ts-ignore
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
new Plugin({ new Plugin({
@@ -41,7 +43,7 @@ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
const id = node.attrs.id; const id = node.attrs.id;
const baseUrl = window.location.href.split('#')[0]; const baseUrl = window.location.href.split('#')[0];
const url = `${baseUrl}#${id}`; const url = `${baseUrl}#${id}`;
navigator.clipboard.writeText(url); copyToClipboard(url);
linkBtnContent.innerHTML = successIcon; linkBtnContent.innerHTML = successIcon;
setTimeout( setTimeout(
() => (linkBtnContent.innerHTML = copyIcon), () => (linkBtnContent.innerHTML = copyIcon),
@@ -1,9 +1,9 @@
import { imageDimensionsFromStream } from "image-dimensions"; import { imageDimensionsFromData } from 'image-dimensions';
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from '../media-utils';
import { IAttachment } from "../types"; import { IAttachment } from '../types';
import { generateNodeId } from "../utils"; import { generateNodeId } from '../utils';
import { Node } from "@tiptap/pm/model"; import { Node } from '@tiptap/pm/model';
import { Command } from "@tiptap/core"; import { Command } from '@tiptap/core';
const findImageNodeByPlaceholderId = ( const findImageNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false; if (result) return false;
if ( if (
node.type.name === "image" && node.type.name === 'image' &&
node.attrs.placeholder?.id === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
@@ -34,7 +34,11 @@ const handleImageUpload =
if (!validated) return; if (!validated) return;
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const imageDimensions = imageDimensionsFromData(
new Uint8Array(await file.arrayBuffer()),
);
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = imageDimensions const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height ? imageDimensions.width / imageDimensions.height
@@ -1,4 +1,4 @@
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps // https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
// https://github.com/sueddeutsche/prosemirror-recreate-transform // https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
export { recreateTransform, RecreateTransform } from "./recreateTransform"; export { recreateTransform, RecreateTransform } from "./recreateTransform";
export type { Options } from "./recreateTransform"; export type { Options } from "./recreateTransform";

Some files were not shown because too many files have changed in this diff Show More