Compare commits

..

2 Commits

Author SHA1 Message Date
Philipinho ea27919079 3 levels 2025-10-15 12:22:55 +01:00
Philipinho 2b6ac81c61 table of contents node - WIP 2025-10-15 12:20:08 +01:00
106 changed files with 1624 additions and 4998 deletions
+2 -4
View File
@@ -1,4 +1,4 @@
FROM node:22-slim AS base FROM node:22-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder FROM base AS builder
@@ -13,9 +13,7 @@ RUN pnpm build
FROM base AS installer FROM base AS installer
RUN apt-get update \ RUN apk add --no-cache curl bash
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.24.1", "version": "0.23.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -27,7 +27,7 @@
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.9.0",
"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",
@@ -57,7 +57,7 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
@@ -65,10 +65,10 @@
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.19.1", "@types/node": "22.10.0",
"@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": "^4.4.1",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"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",
@@ -81,6 +81,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^7.2.4" "vite": "^6.3.5"
} }
} }
@@ -42,7 +42,7 @@
"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? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Details", "Details": "Einzelheiten",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@@ -234,7 +234,9 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -525,47 +527,5 @@
"Delete SSO provider": "SSO-Anbieter löschen", "Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?", "Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion", "Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration", "{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"AI settings": "KI-Einstellungen",
"AI search": "KI-Suche",
"AI Answer": "KI-Antwort",
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Sources": "Quellen",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen"
} }
@@ -554,20 +554,5 @@
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key", "Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace", "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Sources": "Sources",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Eliminar proveedor de SSO", "Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?", "Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción", "Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Supprimer le fournisseur SSO", "Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?", "Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action", "Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Elimina provider SSO", "Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?", "Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione", "Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
"Icon": "Icona",
"Upload image": "Carica immagine",
"Remove image": "Rimuovi immagine",
"Failed to remove image": "Rimozione immagine fallita",
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Sources": "Fonti",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "SSOプロバイダーを削除する", "Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?", "Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション", "Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成", "{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
"Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します。",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用して、ワークスペースコンテンツ全体にわたって意味検索機能を提供します。",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "SSO 제공자 삭제", "Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?", "Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업", "Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거"
} }
@@ -34,7 +34,7 @@
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Werkruimte aanmaken", "Create workspace": "Wwerkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@@ -91,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@@ -527,47 +527,5 @@
"Delete SSO provider": "Verwijder SSO-provider", "Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?", "Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie", "Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie", "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Excluir provedor de SSO", "Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?", "Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação", "Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor"
} }
@@ -498,10 +498,10 @@
"Deleted at": "Удалено в", "Deleted at": "Удалено в",
"Preview": "Предпросмотр", "Preview": "Предпросмотр",
"Subpages": "Подстраницы", "Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить под страницы", "Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц", "No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)", "Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все под страницы", "List all subpages of the current page": "Показать все подстраницы текущей страницы",
"Attachments": "Вложения", "Attachments": "Вложения",
"All spaces": "Все пространства", "All spaces": "Все пространства",
"Unknown": "Неизвестно", "Unknown": "Неизвестно",
@@ -527,47 +527,5 @@
"Delete SSO provider": "Удалить поставщика SSO", "Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?", "Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие", "Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "Видалити постачальника SSO", "Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?", "Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія", "Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}", "{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір"
} }
@@ -527,47 +527,5 @@
"Delete SSO provider": "删除SSO提供商", "Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?", "Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作", "Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置", "{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色"
} }
-2
View File
@@ -37,7 +37,6 @@ import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -108,7 +107,6 @@ export default function App() {
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -12,14 +12,13 @@ import {
IconLock, IconLock,
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
@@ -110,13 +109,6 @@ const groupedData: DataGroup[] = [
isAdmin: true, isAdmin: true,
showDisabledInNonEE: true, showDisabledInNonEE: true,
}, },
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
isSelfhosted: true,
},
], ],
}, },
{ {
@@ -1,113 +0,0 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface AiSearchResultProps {
result?: IAiSearchResponse;
isLoading?: boolean;
streamingAnswer?: string;
streamingSources?: any[];
}
export function AiSearchResult({
result,
isLoading,
streamingAnswer = "",
streamingSources = [],
}: AiSearchResultProps) {
const { t } = useTranslation();
// Use streaming data if available, otherwise fall back to result
const answer = streamingAnswer || result?.answer || "";
const sources =
streamingSources.length > 0 ? streamingSources : result?.sources || [];
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!sources || sources.length === 0) return [];
const pageMap = new Map();
sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [sources]);
if (isLoading && !answer) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">{t("AI is thinking...")}</Text>
</Group>
</Paper>
);
}
if (!answer && !isLoading) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">
{t("AI Answer")}
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
}}
/>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sources")}
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}
@@ -1,69 +0,0 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
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 { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
)}
</Text>
</div>
<AiSearchToggle />
</Group>
</>
);
}
interface AiSearchToggleProps {
size?: MantineSize;
label?: string;
}
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -1,46 +0,0 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
streamingAnswer: string;
streamingSources: any[];
clearStreaming: () => void;
}
export function useAiSearch(): UseAiSearchResult {
const [streamingAnswer, setStreamingAnswer] = useState("");
const [streamingSources, setStreamingSources] = useState<any[]>([]);
const clearStreaming = useCallback(() => {
setStreamingAnswer("");
setStreamingSources([]);
}, []);
const mutation = useMutation({
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
setStreamingAnswer("");
setStreamingSources([]);
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
if (chunk.sources) {
setStreamingSources(chunk.sources);
}
});
},
});
return {
...mutation,
streamingAnswer,
streamingSources,
clearStreaming,
};
}
-61
View File
@@ -1,61 +0,0 @@
import { useState, useCallback, useRef } from "react";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
export function useAiStream() {
const [content, setContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const mutation = useAiGenerateStreamMutation();
const startStream = useCallback(
async (data: AiGenerateDto) => {
setContent("");
setIsStreaming(true);
try {
const controller = await mutation.mutateAsync({
...data,
onChunk: (chunk) => {
setContent((prev) => prev + chunk.content);
},
onError: (error) => {
console.error("AI stream error:", error);
setIsStreaming(false);
},
onComplete: () => {
setIsStreaming(false);
},
});
abortControllerRef.current = controller;
} catch (error) {
console.error("Failed to start stream:", error);
setIsStreaming(false);
}
},
[mutation]
);
const stopStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsStreaming(false);
}
}, []);
const resetContent = useCallback(() => {
setContent("");
}, []);
return {
content,
isStreaming,
startStream,
stopStream,
resetContent,
isLoading: mutation.isPending,
error: mutation.error,
};
}
@@ -1,46 +0,0 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<EnableAiSearch />
</>
);
}
-44
View File
@@ -1,44 +0,0 @@
import {
useMutation,
UseMutationResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
generateAiContent,
generateAiContentStream,
} from "@/ee/ai/services/ai-service.ts";
import {
AiConfigResponse,
AiContentResponse,
AiGenerateDto,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export function useAiGenerateMutation(): UseMutationResult<
AiContentResponse,
Error,
AiGenerateDto
> {
return useMutation({
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
});
}
interface StreamCallbacks {
onChunk: (chunk: AiStreamChunk) => void;
onError?: (error: AiStreamError) => void;
onComplete?: () => void;
}
export function useAiGenerateStreamMutation(): UseMutationResult<
AbortController,
Error,
AiGenerateDto & StreamCallbacks
> {
return useMutation({
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
generateAiContentStream(data, onChunk, onError, onComplete),
});
}
@@ -1,83 +0,0 @@
import api from "@/lib/api-client.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function askAi(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.content) {
answer += parsed.content;
onChunk?.({ content: parsed.content });
}
if (parsed.sources) {
sources = parsed.sources;
onChunk?.({ sources: parsed.sources });
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
// Skip invalid JSON
}
}
}
}
}
return { answer, sources };
}
@@ -1,89 +0,0 @@
import api from "@/lib/api-client.ts";
import {
AiGenerateDto,
AiContentResponse,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export async function generateAiContent(
data: AiGenerateDto,
): Promise<AiContentResponse> {
const req = await api.post<AiContentResponse>("/ai/generate", data);
return req.data;
}
export async function generateAiContentStream(
data: AiGenerateDto,
onChunk: (chunk: AiStreamChunk) => void,
onError?: (error: AiStreamError) => void,
onComplete?: () => void,
): Promise<AbortController> {
const abortController = new AbortController();
try {
const response = await fetch("/api/ai/generate/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal: abortController.signal,
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error("Response body is not readable");
}
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.error) {
onError?.(parsed);
} else {
onChunk(parsed);
}
} catch (e) {
// Ignore parse errors for incomplete chunks
}
}
}
}
} catch (error) {
if (error.name !== "AbortError") {
onError?.({ error: error.message });
}
} finally {
reader.releaseLock();
}
};
processStream();
} catch (error) {
onError?.({ error: error.message });
}
return abortController;
}
-40
View File
@@ -1,40 +0,0 @@
export enum AiAction {
IMPROVE_WRITING = "improve_writing",
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
MAKE_SHORTER = "make_shorter",
MAKE_LONGER = "make_longer",
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
}
export interface AiGenerateDto {
action?: AiAction;
content: string;
prompt?: string;
}
export interface AiContentResponse {
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface AiConfigResponse {
configured: boolean;
availableActions: AiAction[];
}
export interface AiStreamChunk {
content: string;
}
export interface AiStreamError {
error: string;
}
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder withTableBorder
> >
<Table.Caption> <Table.Caption>
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com. To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption> </Table.Caption>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
@@ -144,16 +144,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}, },
}, },
}; };
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps}>
@@ -164,8 +164,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -175,8 +175,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -1,5 +1,5 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react"; import { IconCheck, IconPalette } from "@tabler/icons-react";
import { import {
ActionIcon, ActionIcon,
Button, Button,
@@ -8,9 +8,6 @@ import {
ScrollArea, ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid,
Box,
Stack,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
@@ -64,12 +61,9 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
name: "Gray", name: "Gray",
color: "#A8A29E", color: "#A8A29E",
}, },
{
name: "Brown",
color: "#92400E",
},
]; ];
// TODO: handle dark mode
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{ {
name: "Default", name: "Default",
@@ -77,39 +71,35 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
}, },
{ {
name: "Blue", name: "Blue",
color: "#98d8f2", color: "#c1ecf9",
}, },
{ {
name: "Green", name: "Green",
color: "#7edb6c", color: "#acf79f",
}, },
{ {
name: "Purple", name: "Purple",
color: "#e0d6ed", color: "#f6f3f8",
}, },
{ {
name: "Red", name: "Red",
color: "#ffc6c2", color: "#fdebeb",
}, },
{ {
name: "Yellow", name: "Yellow",
color: "#faf594", color: "#fbf4a2",
}, },
{ {
name: "Orange", name: "Orange",
color: "#f5c8a9", color: "#faebdd",
}, },
{ {
name: "Pink", name: "Pink",
color: "#f5cfe0", color: "#faf1f5",
}, },
{ {
name: "Gray", name: "Gray",
color: "#dfdfd7", color: "#f1f1ef",
},
{
name: "Brown",
color: "#d7c4b7",
}, },
]; ];
@@ -122,21 +112,17 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
selector: (ctx) => { selector: ctx => {
if (!ctx.editor) { if (!ctx.editor) {
return null; return null;
} }
const activeColors: Record<string, boolean> = {}; const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => { TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
color,
});
}); });
HIGHLIGHT_COLORS.forEach(({ color }) => { HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
color,
});
}); });
return activeColors; return activeColors;
@@ -147,152 +133,67 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return null; return null;
} }
const activeColorItem = TEXT_COLORS.find( const activeColorItem = TEXT_COLORS.find(({ color }) =>
({ color }) => editorState[`text_${color}`], editorState[`text_${color}`]
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find( const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
({ color }) => editorState[`highlight_${color}`], editorState[`highlight_${color}`]
); );
return ( return (
<Popover width={220} opened={isOpen} withArrow> <Popover width={200} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<Button <ActionIcon
variant="default" variant="default"
size="lg"
radius="0" radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
style={{ style={{
height: "34px",
border: "none", border: "none",
fontWeight: 500, color: activeColorItem?.color,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}} }}
onClick={() => setIsOpen(!isOpen)}
> >
A <IconPalette size={16} stroke={2} />
</Button> </ActionIcon>
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400"> <ScrollArea.Autosize type="scroll" mah="400">
<Stack gap="md"> <Text span c="dimmed" tt="uppercase" inherit>
<Box> {t("Color")}
<Text size="sm" fw={600} mb="xs"> </Text>
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
}}
style={{
width: rem(28),
height: rem(28),
borderRadius: rem(6),
border: editorState[`text_${color}`]
? "2px solid var(--mantine-color-gray-8)"
: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: color || "var(--mantine-color-gray-8)",
}}
>
A
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Box> <Button.Group orientation="vertical">
<Text size="sm" fw={600} mb="xs"> {TEXT_COLORS.map(({ name, color }, index) => (
{t("Highlight color")} <Button
</Text> key={index}
<SimpleGrid cols={5} spacing="xs"> variant="default"
{HIGHLIGHT_COLORS.map(({ name, color }, index) => ( leftSection={<span style={{ color }}>A</span>}
<Tooltip key={index} label={t(name)} withArrow> justify="left"
<Box fullWidth
onClick={() => { rightSection={
if (name === "Default") { editorState[`text_${color}`] && (
editor.commands.unsetHighlight(); <IconCheck style={{ width: rem(16) }} />
} else { )
editor }
.chain() onClick={() => {
.focus() if (name === "Default") {
.toggleMark("highlight", { editor.commands.unsetColor();
color: color || "", } else {
colorName: name.toLowerCase() || "", editor.chain().focus().setColor(color || "").run();
}) }
.run(); setIsOpen(false);
} }}
setIsOpen(false); style={{ border: "none" }}
}} >
style={{ {t(name)}
width: rem(28), </Button>
height: rem(28), ))}
borderRadius: rem(4), </Button.Group>
backgroundColor: color || "var(--mantine-color-gray-2)",
border: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: "var(--mantine-color-gray-8)",
}}
>
{editorState[`highlight_${color}`] ? (
<IconCheck
size={16}
color="var(--mantine-color-green-7)"
/>
) : (
"A"
)}
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core"; import { useComputedColorScheme } from "@mantine/core";
import DOMPurify from "dompurify";
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@@ -38,7 +37,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => { .catch((err) => {
if (props.editor.isEditable) { if (props.editor.isEditable) {
setPreview( setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`, `<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
); );
} else { } else {
setPreview( setPreview(
@@ -34,9 +34,7 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; createMentionAction(url, view, pos, creatorId);
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true; return true;
} }
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <ResizableWrapper
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
@@ -118,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<ReactClearModal <ReactClearModal
style={{ style={{
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Image <Image
radius="md" radius="md"
fit="contain" fit="contain"
@@ -9,7 +9,6 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchorId?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -19,7 +18,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId, anchorId) => { async (url: string, view, pos, creatorId) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -36,7 +35,6 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchorId: anchorId,
}); });
if (!node) return; if (!node) return;
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const { const {
@@ -27,11 +27,10 @@ export default function MentionView(props: NodeViewProps) {
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchorId,
}); });
return ( return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle> <NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && ( {entityType === "user" && (
<Text className={classes.userMention} component="span"> <Text className={classes.userMention} component="span">
@{label} @{label}
@@ -43,7 +42,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
} }
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -20,6 +20,7 @@ import {
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
IconAlignLeft2,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -153,6 +154,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(), editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
}, },
{
title: "Table of contents",
description: "Insert table of contents",
searchTerms: ["toc"],
icon: IconAlignLeft2,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).insertTableOfContents().run(),
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -52,7 +52,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (error && !shareId) { if (error && !shareId) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")} {t("Failed to load subpages")}
</Text> </Text>
@@ -62,7 +62,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (subpages.length === 0) { if (subpages.length === 0) {
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("No subpages")} {t("No subpages")}
@@ -73,7 +73,7 @@ export default function SubpagesView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<div className={classes.container}> <div className={classes.container}>
<Stack gap={5}> <Stack gap={5}>
{subpages.map((page) => ( {subpages.map((page) => (
@@ -0,0 +1,61 @@
.header {
}
.container {
}
.emptyState {
}
.link {
outline: none;
cursor: pointer;
display: block;
width: 100%;
text-align: start;
word-wrap: break-word;
background-color: transparent;
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
line-height: var(--mantine-line-height-sm);
padding: 6px;
border-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
border: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
@media (max-width: $mantine-breakpoint-sm) {
& {
border: none !important;
padding-left: 0px;
}
}
}
.linkActive {
font-weight: 500;
border-left-color: light-dark(
var(--mantine-color-grey-5),
var(--mantine-color-grey-3)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
&,
&:hover {
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-5)
) !important;
}
}
.leftBorder {
border-left: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -0,0 +1,55 @@
import { Editor as CoreEditor } from "@tiptap/core";
import { TableOfContentsStorage } from "@tiptap/extension-table-of-contents";
import { NodeViewWrapper, useEditorState } from "@tiptap/react";
import { memo } from "react";
import { clsx } from "clsx";
import classes from "./table-of-contents-nodeview.module.css";
export type TableOfContentsProps = {
editor: CoreEditor;
onItemClick?: () => void;
};
export const TableOfContentsNodeview = memo(
({ editor, onItemClick }: TableOfContentsProps) => {
const content = useEditorState({
editor,
selector: (ctx) =>
(ctx.editor.storage.tableOfContents as TableOfContentsStorage)?.content,
});
return (
<NodeViewWrapper>
<div contentEditable={false}>
<div className={classes.header}>Table of contents</div>
{content.length > 0 ? (
<div className={classes.container}>
{content
.filter((item) => item.level <= 3)
.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
style={{ marginLeft: `${1 * item.level - 1}rem` }}
onClick={onItemClick}
className={clsx(
classes.link,
item.isActive && classes.linkActive,
)}
>
{item.itemIndex}. {item.textContent}
</a>
))}
</div>
) : (
<div className={classes.emptyState}>
Start adding headlines to your document
</div>
)}
</div>
</NodeViewWrapper>
);
},
);
TableOfContentsNodeview.displayName = "TableOfContentsNodeview";
@@ -15,7 +15,7 @@ export default function VideoView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper>
<video <video
preload="metadata" preload="metadata"
width={width} width={width}
@@ -1,20 +1,18 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { CharacterCount } from "@tiptap/extension-character-count";
import { TaskList } from "@tiptap/extension-task-list"; import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item"; import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline"; import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; import { TableOfContents as TiptapTableOfContents } from "@tiptap/extension-table-of-contents";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
@@ -43,9 +41,7 @@ import {
Mention, Mention,
Subpages, Subpages,
TableDndExtension, TableDndExtension,
Heading, TableOfContentsNode,
Highlight,
UniqueID,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -54,8 +50,11 @@ import {
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import ImageView from "@/features/editor/components/image/image-view.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
@@ -63,7 +62,6 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap"; import abap from "highlightjs-sap-abap";
@@ -80,7 +78,9 @@ import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import { TableOfContentsNodeview } from "@/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -96,7 +96,6 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false,
history: false, history: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
@@ -109,11 +108,6 @@ export const mainExtensions = [
}, },
}, },
}), }),
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === "heading") { if (node.type.name === "heading") {
@@ -134,7 +128,6 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline, Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
@@ -251,6 +244,10 @@ export const mainExtensions = [
}; };
}, },
}).configure(), }).configure(),
TiptapTableOfContents,
TableOfContentsNode.configure({
view: TableOfContentsNodeview,
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -1,58 +0,0 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
function waitForState(checkFn: () => boolean): Promise<void> {
return new Promise((resolve) => {
const interval = setInterval(() => {
if (checkFn()) {
clearInterval(interval);
resolve();
}
}, 800);
});
}
export const useEditorScroll = ({
canScroll,
initialScrollTo,
}: {
canScroll: () => boolean;
initialScrollTo?: string;
}) => {
const [scrollTo, setScrollTo] = useState<string>(initialScrollTo || "");
useEffect(() => {
if (!initialScrollTo) {
setScrollTo(window.location.hash ? window.location.hash.slice(1) : "");
}
}, [initialScrollTo]);
const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => {
await waitForState(() => canScroll());
return new Promise((resolve) => {
const MAX_TRY_COUNT = 10;
if (tryCount >= MAX_TRY_COUNT) {
resolve(false);
return;
}
const targetId = _scrollTo || scrollTo;
if (!targetId) {
resolve(false);
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
} else {
setTimeout(async () => {
resolve(await handleScrollTo(editor, targetId, tryCount + 1));
}, 200);
}
});
}, [scrollTo, canScroll]);
return { scrollTo, handleScrollTo };
};
@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
@@ -56,7 +56,6 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -69,16 +68,7 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
@@ -104,9 +94,7 @@ export default function PageEditor({
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
@@ -276,8 +264,6 @@ export default function PageEditor({
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor);
editorCreated.current = true;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -1,14 +1,13 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useMemo } 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 } from "@tiptap/extension-heading";
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";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -22,34 +21,9 @@ export default function ReadonlyPageEditor({
pageId, pageId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated],
);
const initialScrollTo = window.location.hash
? window.location.hash.slice(1)
: "";
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
useEffect(() => {
isComponentMounted.current = true;
}, []);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const filteredExtensions = mainExtensions.filter( return [...mainExtensions];
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []); }, []);
const titleExtensions = [ const titleExtensions = [
@@ -85,9 +59,6 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
@@ -5,7 +5,7 @@
); );
color: light-dark( color: light-dark(
var(--mantine-color-default-color), var(--mantine-color-default-color),
var(--mantine-color-white) var(--mantine-color-dark-0)
); );
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl); line-height: var(--mantine-line-height-xl);
@@ -115,7 +115,7 @@
} }
& > .react-renderer { & > .react-renderer {
margin-top: var(--mantine-spacing-sm); margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm); margin-bottom: var(--mantine-spacing-sm);
&:first-child { &:first-child {
@@ -141,7 +141,7 @@
.selection, .selection,
*::selection { *::selection {
background-color: light-dark(Highlight, var(--mantine-color-gray-7)); background-color: Highlight;
} }
.comment-mark { .comment-mark {
@@ -186,39 +186,6 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
}
.ProseMirror > h1,
.ProseMirror > h2,
.ProseMirror > h3,
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
left: 5px;
top: 0;
height: 100%;
transition: opacity 0.15s ease;
display: inline-flex;
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
scroll-margin-top: 80px; /* match your header height */
} }
.ProseMirror-icon { .ProseMirror-icon {
@@ -242,3 +209,4 @@
.actionIconGroup { .actionIconGroup {
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
@@ -1,177 +0,0 @@
/* Highlight colors with dark mode support */
.ProseMirror {
/* Blue */
mark[data-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgba(37, 99, 235, 0.35)
) !important;
}
/* Green */
mark[data-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgba(0, 138, 0, 0.35)
) !important;
}
/* Purple */
mark[data-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgba(147, 51, 234, 0.35)
) !important;
}
/* Red */
mark[data-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgba(224, 0, 0, 0.35)
) !important;
}
/* Yellow */
mark[data-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgba(234, 179, 8, 0.35)
) !important;
}
/* Orange */
mark[data-color="#f5c8a9"] {
background-color: light-dark(
rgb(251, 236, 221),
rgba(255, 165, 0, 0.45)
) !important;
}
/* Pink */
mark[data-color="#f5cfe0"] {
background-color: light-dark(
rgb(252, 241, 246),
rgba(186, 64, 129, 0.35)
) !important;
}
/* Gray */
mark[data-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgba(168, 162, 158, 0.35)
) !important;
}
/* Brown */
mark[data-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgba(146, 64, 14, 0.35)
) !important;
}
}
/* Color selector trigger button styles */
.color-selector-trigger[data-text-color="#2563EB"] {
color: #2563EB !important;
}
.color-selector-trigger[data-text-color="#008A00"] {
color: #008A00 !important;
}
.color-selector-trigger[data-text-color="#9333EA"] {
color: #9333EA !important;
}
.color-selector-trigger[data-text-color="#E00000"] {
color: #E00000 !important;
}
.color-selector-trigger[data-text-color="#EAB308"] {
color: #EAB308 !important;
}
.color-selector-trigger[data-text-color="#FFA500"] {
color: #FFA500 !important;
}
.color-selector-trigger[data-text-color="#BA4081"] {
color: #BA4081 !important;
}
.color-selector-trigger[data-text-color="#A8A29E"] {
color: #A8A29E !important;
}
.color-selector-trigger[data-text-color="#92400E"] {
color: #92400E !important;
}
/* Highlight background colors with light-dark support - solid colors for trigger button */
.color-selector-trigger[data-highlight-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgb(30 64 175)
) !important;
}
.color-selector-trigger[data-highlight-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgb(21 128 61)
) !important;
}
.color-selector-trigger[data-highlight-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgb(107 33 168)
) !important;
}
.color-selector-trigger[data-highlight-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgb(185 28 28)
) !important;
}
.color-selector-trigger[data-highlight-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgb(161 98 7)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5c8a9"] {
background-color: light-dark(
rgb(251 236 221),
rgb(194 65 12)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5cfe0"] {
background-color: light-dark(
rgb(252 241 246),
rgb(157 23 77)
) !important;
}
.color-selector-trigger[data-highlight-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgb(115 115 115)
) !important;
}
.color-selector-trigger[data-highlight-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgb(120 53 15)
) !important;
}
@@ -12,4 +12,3 @@
@import "./find.css"; @import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css"; @import "./ordered-list.css";
@import "./highlight.css";
@@ -104,10 +104,7 @@ export function TitleEditor({
}); });
useEffect(() => { useEffect(() => {
const anchorId = window.location.hash const pageSlug = buildPageUrl(spaceSlug, slugId, title);
? window.location.hash.substring(1)
: undefined;
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
navigate(pageSlug, { replace: true }); navigate(pageSlug, { replace: true });
}, [title]); }, [title]);
@@ -195,43 +192,10 @@ export function TitleEditor({
const { key } = event; const { key } = event;
const { $head } = titleEditor.state.selection; const { $head } = titleEditor.state.selection;
if (key === "Enter") {
event.preventDefault();
const { $from } = titleEditor.state.selection;
const titleText = titleEditor.getText();
// Get the text offset within the heading node (not document position)
const textOffset = $from.parentOffset;
const textAfterCursor = titleText.slice(textOffset);
// Delete text after cursor from title (this will be in undo history)
const endPos = titleEditor.state.doc.content.size;
if (textAfterCursor) {
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
}
// Don't add to history so undo in page editor won't remove this split
pageEditor
.chain()
.command(({ tr }) => {
tr.setMeta("addToHistory", false);
return true;
})
.insertContentAt(0, {
type: "paragraph",
content: textAfterCursor
? [{ type: "text", text: textAfterCursor }]
: undefined,
})
.focus("start")
.run();
return;
}
const shouldFocusEditor = const shouldFocusEditor =
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter); key === "Enter" ||
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) { if (shouldFocusEditor) {
pageEditor.commands.focus("start"); pageEditor.commands.focus("start");
+6 -13
View File
@@ -15,29 +15,22 @@ export const buildPageUrl = (
spaceName: string, spaceName: string,
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchorId?: string,
): string => { ): string => {
let url: string;
if (spaceName === undefined) { if (spaceName === undefined) {
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`; return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchorId ? `${url}#${anchorId}` : url; return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}; };
export const buildSharedPageUrl = (opts: { export const buildSharedPageUrl = (opts: {
shareId: string; shareId: string;
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchorId?: string;
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle, anchorId } = opts; const { shareId, pageSlugId, pageTitle } = opts;
let url: string;
if (!shareId) { if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchorId ? `${url}#${anchorId}` : url;
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}; };
@@ -9,7 +9,6 @@ import {
ScrollArea, ScrollArea,
Avatar, Avatar,
Group, Group,
Switch,
getDefaultZIndex, getDefaultZIndex,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -18,7 +17,6 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -26,21 +24,15 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
onAskClick?: () => void;
spaceId?: string; spaceId?: string;
isAiMode?: boolean;
} }
export function SearchSpotlightFilters({ export function SearchSpotlightFilters({
onFiltersChange, onFiltersChange,
onAskClick,
spaceId, spaceId,
isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -50,7 +42,6 @@ export function SearchSpotlightFilters({
const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page"); const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1, page: 1,
@@ -129,31 +120,6 @@ export function SearchSpotlightFilters({
return ( return (
<div className={classes.filtersContainer}> <div className={classes.filtersContainer}>
{workspace?.settings?.ai?.search === true && (
<div
style={{
display: "flex",
alignItems: "center",
height: "32px",
paddingLeft: "8px",
paddingRight: "8px",
}}
>
<Switch
checked={isAiMode}
onChange={(event) => onAskClick()}
label={t("Ask AI")}
size="sm"
color="blue"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
}}
/>
</div>
)}
<Menu <Menu
shadow="md" shadow="md"
width={250} width={250}
@@ -265,7 +231,7 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={option.disabled || (isAiMode && option.value === "attachment")} disabled={option.disabled}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -275,11 +241,6 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("Ask AI not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -1,16 +1,12 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { Group, Button } from "@mantine/core"; import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { searchSpotlightStore } from "../constants.ts"; import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx"; import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts"; import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -28,7 +24,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}>({ }>({
contentType: "page", contentType: "page",
}); });
const [isAiMode, setIsAiMode] = useState(false);
// Build unified search params // Build unified search params
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
@@ -45,42 +40,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return params; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch( const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
searchParams,
!isAiMode // Disable regular search when in AI mode
);
const {
//@ts-ignore
data: aiSearchResult,
//@ts-ignore
isPending: isAiLoading,
//@ts-ignore
mutate: triggerAiSearchMutation,
//@ts-ignore
reset: resetAiMutation,
//@ts-ignore
error: aiSearchError,
streamingAnswer,
streamingSources,
clearStreaming,
} = useAiSearch();
// Clear streaming state and mutation data when query changes (user is typing a new query)
useEffect(() => {
clearStreaming();
resetAiMutation();
}, [query, clearStreaming, resetAiMutation]);
// Show error notification when AI search fails
useEffect(() => {
if (aiSearchError) {
notifications.show({
message: aiSearchError.message || t("AI search failed. Please try again."),
color: "red",
position: "top-center"
});
}
}, [aiSearchError, t]);
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
@@ -99,16 +59,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
setFilters(newFilters); setFilters(newFilters);
}; };
const handleAskClick = () => {
setIsAiMode(!isAiMode);
};
const handleAiSearchTrigger = () => {
if (query.trim() && isAiMode) {
triggerAiSearchMutation(searchParams);
}
};
return ( return (
<> <>
<Spotlight.Root <Spotlight.Root
@@ -122,30 +72,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
backgroundOpacity: 0.55, backgroundOpacity: 0.55,
}} }}
> >
<Group gap="xs" px="sm" pt="sm" pb="xs"> <Spotlight.Search
<Spotlight.Search placeholder={t("Search...")}
placeholder={isAiMode ? t("Ask a question...") : t("Search...")} leftSection={<IconSearch size={20} stroke={1.5} />}
leftSection={<IconSearch size={20} stroke={1.5} />} />
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) {
e.preventDefault();
handleAiSearchTrigger();
}
}}
/>
{isAiMode && hasLicenseKey && (
<Button
size="xs"
leftSection={<IconSparkles size={16} />}
onClick={handleAiSearchTrigger}
disabled={!query.trim()}
loading={isAiLoading}
>
Ask
</Button>
)}
</Group>
<div <div
style={{ style={{
@@ -154,43 +84,20 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
> >
<SearchSpotlightFilters <SearchSpotlightFilters
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
onAskClick={handleAskClick}
spaceId={spaceId} spaceId={spaceId}
isAiMode={isAiMode}
/> />
</div> </div>
<Spotlight.ActionsList> <Spotlight.ActionsList>
{isAiMode ? ( {query.length === 0 && resultItems.length === 0 && (
<> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
{query.length === 0 && (
<Spotlight.Empty>{t("Ask a question...")}</Spotlight.Empty>
)}
{query.length > 0 && (isAiLoading || aiSearchResult || streamingAnswer) && (
<AiSearchResult
result={aiSearchResult}
isLoading={isAiLoading}
streamingAnswer={streamingAnswer}
streamingSources={streamingSources}
/>
)}
{query.length > 0 && !isAiLoading && !aiSearchResult && (
<Spotlight.Empty>{t("No answer available")}</Spotlight.Empty>
)}
</>
) : (
<>
{query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</>
)} )}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>}
</Spotlight.ActionsList> </Spotlight.ActionsList>
</Spotlight.Root> </Spotlight.Root>
</> </>
@@ -19,7 +19,6 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
export function useUnifiedSearch( export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -39,6 +38,6 @@ export function useUnifiedSearch(
return await searchPage(backendParams); return await searchPage(backendParams);
} }
}, },
enabled: !!params.query && enabled, enabled: !!params.query,
}); });
} }
@@ -62,17 +62,14 @@ export default function SpaceSettingsModal({
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
<ScrollArea h={580} scrollbarSize={5} pr={8}> <ScrollArea h={550} scrollbarSize={4} pr={8}>
<div style={{ paddingBottom: "100px"}}> <SpaceDetails
<SpaceDetails spaceId={space?.id}
spaceId={space?.id} readOnly={spaceAbility.cannot(
readOnly={spaceAbility.cannot( SpaceCaslAction.Manage,
SpaceCaslAction.Manage, SpaceCaslSubject.Settings,
SpaceCaslSubject.Settings, )}
)} />
/>
</div>
</ScrollArea> </ScrollArea>
</Tabs.Panel> </Tabs.Panel>
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
return ( return (
<> <>
<SearchInput onSearch={handleSearch} /> <SearchInput onSearch={handleSearch} />
<ScrollArea h={450}> <ScrollArea h={400}>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
@@ -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;
} }
@@ -9,7 +9,7 @@ export interface IWorkspace {
defaultSpaceId: string; defaultSpaceId: string;
customDomain: string; customDomain: string;
enableInvite: boolean; enableInvite: boolean;
settings: IWorkspaceSettings; settings: any;
status: string; status: string;
enforceSso: boolean; enforceSso: boolean;
stripeCustomerId: string; stripeCustomerId: string;
@@ -24,14 +24,6 @@ export interface IWorkspace {
enforceMfa?: boolean; enforceMfa?: boolean;
} }
export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings;
}
export interface IWorkspaceAiSettings {
search?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
+2 -2
View File
@@ -1,4 +1,4 @@
export const INTERNAL_LINK_REGEX = export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/; /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
export const FIVE_MINUTES = 5 * 60 * 1000; export const FIVE_MINUTES = 5 * 60 * 1000;
+1 -1
View File
@@ -51,7 +51,7 @@ root.render(
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}> <MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} zIndex={10000} /> <Notifications position="bottom-center" limit={3} />
<HelmetProvider> <HelmetProvider>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<App /> <App />
-1
View File
@@ -21,7 +21,6 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() { export default function Page() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { const {
data: page, data: page,
isLoading, isLoading,
+18 -25
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.24.1", "version": "0.23.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,9 +30,6 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0",
@@ -40,55 +37,51 @@
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.1.9", "@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9", "@nestjs/core": "^11.1.3",
"@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.9", "@nestjs/platform-fastify": "^11.1.3",
"@nestjs/platform-socket.io": "^11.1.9", "@nestjs/platform-socket.io": "^11.1.3",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.3",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^5.0.65", "bcrypt": "^5.1.1",
"ai-sdk-ollama": "^0.12.0", "bullmq": "^5.61.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.1",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"happy-dom": "20.0.10", "happy-dom": "^18.0.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
"mammoth": "^1.11.0", "mammoth": "^1.10.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.3",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.54",
"pg": "^8.16.3", "pg": "^8.16.0",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"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",
@@ -99,7 +92,7 @@
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0", "typesense": "^2.1.0",
"ws": "^8.18.3", "ws": "^8.18.2",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -5,12 +5,12 @@ import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline'; import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import { import {
Heading,
Callout, Callout,
Comment, Comment,
CustomCodeBlock, CustomCodeBlock,
@@ -33,9 +33,7 @@ import {
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
Highlight, TableOfContentsNode,
UniqueID,
addUniqueIdsToDoc,
} 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';
@@ -47,11 +45,6 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
heading: false,
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph'],
}), }),
Comment, Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
@@ -88,6 +81,7 @@ export const tiptapExtensions = [
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
TableOfContentsNode,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -95,14 +89,7 @@ export function jsonToHtml(tiptapJson: any) {
} }
export function htmlToJson(html: string) { export function htmlToJson(html: string) {
const pmJson = generateJSON(html, tiptapExtensions); return generateJSON(html, tiptapExtensions);
try {
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
} catch (error) {
console.warn('failed to add unique ids to doc', error);
return pmJson;
}
} }
export function jsonToText(tiptapJson: JSONContent) { export function jsonToText(tiptapJson: JSONContent) {
@@ -35,7 +35,6 @@ export class PersistenceExtension implements Extension {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -169,11 +168,6 @@ export class PersistenceExtension implements Extension {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
mentions: pageMentions, mentions: pageMentions,
} as IPageBacklinkJob); } as IPageBacklinkJob);
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
} }
} }
@@ -2,17 +2,7 @@ export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated', COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created', PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated', PAGE_UPDATED = 'page.updated',
PAGE_CONTENT_UPDATED = 'page-content-updated',
PAGE_MOVED_TO_SPACE = 'page-moved-to-space',
PAGE_DELETED = 'page.deleted', PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted', PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored', PAGE_RESTORED = 'page.restored',
SPACE_CREATED = 'space.created',
SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted',
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
} }
+14 -24
View File
@@ -1,23 +1,23 @@
import { import {
BadRequestException,
Body,
Controller, Controller,
ForbiddenException, Post,
Body,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
NotFoundException,
Post,
UseGuards, UseGuards,
ForbiddenException,
NotFoundException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PageService } from './services/page.service'; import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto'; import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto'; import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto'; import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import { import {
DeletePageDto,
PageHistoryIdDto, PageHistoryIdDto,
PageIdDto, PageIdDto,
PageInfoDto, PageInfoDto,
DeletePageDto,
} from './dto/page.dto'; } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
@@ -106,11 +106,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('delete') @Post('delete')
async delete( async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
@Body() deletePageDto: DeletePageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(deletePageDto.pageId); const page = await this.pageRepo.findById(deletePageDto.pageId);
if (!page) { if (!page) {
@@ -126,27 +122,19 @@ export class PageController {
'Only space admins can permanently delete pages', 'Only space admins can permanently delete pages',
); );
} }
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id); await this.pageService.forceDelete(deletePageDto.pageId);
} else { } else {
// Soft delete requires page manage permissions // Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
await this.pageService.removePage( await this.pageService.remove(deletePageDto.pageId, user.id);
deletePageDto.pageId,
user.id,
workspace.id,
);
} }
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('restore') @Post('restore')
async restore( async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
@Body() pageIdDto: PageIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(pageIdDto.pageId); const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) { if (!page) {
@@ -158,11 +146,13 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id); await this.pageRepo.restorePage(pageIdDto.pageId);
return this.pageRepo.findById(pageIdDto.pageId, { // Return the restored page data with hasChildren info
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true, includeHasChildren: true,
}); });
return restoredPage;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -51,7 +51,6 @@ export class PageService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
) {} ) {}
@@ -256,11 +255,6 @@ export class PageService {
pageIds, pageIds,
trx, trx,
); );
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
workspaceId: rootPage.workspaceId
});
} }
}); });
} }
@@ -387,9 +381,9 @@ export class PageService {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: page.id === rootPage.id parentPageId: page.parentPageId
? (isDuplicateInSameSpace ? rootPage.parentPageId : null) ? pageMap.get(page.parentPageId)?.newPageId
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null), : null,
}; };
}), }),
); );
@@ -399,7 +393,6 @@ export class PageService {
const insertedPageIds = insertablePages.map((page) => page.id); const insertedPageIds = insertablePages.map((page) => page.id);
this.eventEmitter.emit(EventName.PAGE_CREATED, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: insertedPageIds, pageIds: insertedPageIds,
workspaceId: authUser.workspaceId,
}); });
//TODO: best to handle this in a queue //TODO: best to handle this in a queue
@@ -587,7 +580,7 @@ export class PageService {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
} }
async forceDelete(pageId: string, workspaceId: string): Promise<void> { async forceDelete(pageId: string): Promise<void> {
// Get all descendant IDs (including the page itself) using recursive CTE // Get all descendant IDs (including the page itself) using recursive CTE
const descendants = await this.db const descendants = await this.db
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
@@ -630,16 +623,11 @@ export class PageService {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
this.eventEmitter.emit(EventName.PAGE_DELETED, { this.eventEmitter.emit(EventName.PAGE_DELETED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId,
}); });
} }
} }
async removePage( async remove(pageId: string, userId: string): Promise<void> {
pageId: string, await this.pageRepo.removePage(pageId, userId);
userId: string,
workspaceId: string,
): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId);
} }
} }
@@ -62,7 +62,7 @@ export class SearchService {
) )
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit | 25) .limit(searchParams.limit | 20)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
if (!searchParams.shareId) { if (!searchParams.shareId) {
+2 -2
View File
@@ -69,8 +69,8 @@ export class ShareService {
return await this.shareRepo.insertShare({ return await this.shareRepo.insertShare({
key: nanoIdGen().toLowerCase(), key: nanoIdGen().toLowerCase(),
pageId: page.id, pageId: page.id,
includeSubPages: createShareDto.includeSubPages ?? false, includeSubPages: createShareDto.includeSubPages || true,
searchIndexing: createShareDto.searchIndexing ?? false, searchIndexing: createShareDto.searchIndexing || true,
creatorId: authUserId, creatorId: authUserId,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId, workspaceId,
@@ -22,12 +22,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
restrictApiToAdmins: boolean; restrictApiToAdmins: boolean;
@IsOptional()
@IsBoolean()
aiSearch: boolean;
@IsOptional()
@IsBoolean()
generativeAi: boolean;
} }
@@ -33,7 +33,6 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -51,7 +50,6 @@ export class WorkspaceService {
@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,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
async findById(workspaceId: string) { async findById(workspaceId: string) {
@@ -314,51 +312,6 @@ export class WorkspaceService {
delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.restrictApiToAdmins;
} }
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
);
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else {
// Schedule deletion after 24 hours
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
delete updateWorkspaceDto.aiSearch;
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
);
delete updateWorkspaceDto.generativeAi;
}
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, {
@@ -1,22 +0,0 @@
import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
export async function isPageEmbeddingsTableExists(db: KyselyDB) {
return tableExists({ db, tableName: 'page_embeddings' });
}
export async function tableExists(opts: {
db: KyselyDB;
tableName: string;
}): Promise<boolean> {
const { db, tableName } = opts;
const result = await sql<{ exists: boolean }>`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = COALESCE(current_schema(), 'public')
AND table_name = ${tableName}
) as exists
`.execute(db);
return result.rows[0]?.exists ?? false;
}
@@ -4,11 +4,9 @@ import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class PageEvent { export class PageEvent {
pageIds: string[]; pageIds: string[];
workspaceId: string;
} }
@Injectable() @Injectable()
@@ -16,65 +14,36 @@ export class PageListener {
private readonly logger = new Logger(PageListener.name); private readonly logger = new Logger(PageListener.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
@OnEvent(EventName.PAGE_CREATED) @OnEvent(EventName.PAGE_CREATED)
async handlePageCreated(event: PageEvent) { async handlePageCreated(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds } = event;
if (this.isTypesense()) { await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds });
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
pageIds,
});
}
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
} }
@OnEvent(EventName.PAGE_UPDATED) @OnEvent(EventName.PAGE_UPDATED)
async handlePageUpdated(event: PageEvent) { async handlePageUpdated(event: PageEvent) {
const { pageIds } = event; const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds }); await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
} }
@OnEvent(EventName.PAGE_DELETED) @OnEvent(EventName.PAGE_DELETED)
async handlePageDeleted(event: PageEvent) { async handlePageDeleted(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds } = event;
if (this.isTypesense()) { await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
} }
@OnEvent(EventName.PAGE_SOFT_DELETED) @OnEvent(EventName.PAGE_SOFT_DELETED)
async handlePageSoftDeleted(event: PageEvent) { async handlePageSoftDeleted(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
pageIds,
workspaceId,
});
} }
@OnEvent(EventName.PAGE_RESTORED) @OnEvent(EventName.PAGE_RESTORED)
async handlePageRestored(event: PageEvent) { async handlePageRestored(event: PageEvent) {
const { pageIds, workspaceId } = event; const { pageIds } = event;
if (this.isTypesense()) { await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
}
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
} }
} }
@@ -1,36 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class SpaceEvent {
spaceId: string;
}
@Injectable()
export class SpaceListener {
private readonly logger = new Logger(SpaceListener.name);
constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {}
@OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
}
@@ -1,36 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class WorkspaceEvent {
workspaceId: string;
}
@Injectable()
export class WorkspaceListener {
private readonly logger = new Logger(WorkspaceListener.name);
constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {}
@OnEvent(EventName.WORKSPACE_DELETED)
async handlePageDeleted(event: WorkspaceEvent) {
const { workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
}
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
}
@@ -1,196 +0,0 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_group_users_user_id')
.ifNotExists()
.on('group_users')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_user_id')
.ifNotExists()
.on('space_members')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_group_id')
.ifNotExists()
.on('space_members')
.column('group_id')
.execute();
// Page tree
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
ON pages (space_id, parent_page_id, position COLLATE "C")
WHERE deleted_at IS NULL
`.execute(db);
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
ON pages (parent_page_id)
WHERE deleted_at IS NULL
`.execute(db);
// Recent pages query
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_updated
ON pages (space_id, updated_at DESC)
WHERE deleted_at IS NULL
`.execute(db);
// Trash view
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
ON pages (space_id, deleted_at DESC)
WHERE deleted_at IS NOT NULL
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
ON workspaces (LOWER(hostname))
`.execute(db);
await db.schema
.createIndex('idx_workspaces_created_at')
.ifNotExists()
.on('workspaces')
.column('created_at')
.execute();
await db.schema
.createIndex('idx_users_workspace_deleted_created')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'deleted_at', 'created_at'])
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_users_workspace_email_lower
ON users (workspace_id, LOWER(email))
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_workspace_slug_lower
ON spaces (workspace_id, LOWER(slug))
`.execute(db);
await db.schema
.createIndex('idx_spaces_workspace_created')
.ifNotExists()
.on('spaces')
.columns(['workspace_id', 'created_at'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_workspace_name_lower
ON groups (workspace_id, LOWER(name))
`.execute(db);
await db.schema
.createIndex('idx_groups_workspace_id')
.ifNotExists()
.on('groups')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_shares_page_id')
.ifNotExists()
.on('shares')
.column('page_id')
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_shares_key_lower
ON shares (LOWER(key))
`.execute(db);
await db.schema
.createIndex('idx_attachments_page_id')
.ifNotExists()
.on('attachments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_space_id')
.ifNotExists()
.on('attachments')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_comments_page_id')
.ifNotExists()
.on('comments')
.columns(['page_id', 'created_at'])
.execute();
await db.schema
.createIndex('idx_comments_parent_comment_id')
.ifNotExists()
.on('comments')
.column('parent_comment_id')
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_page_history_page_created
ON page_history (page_id, created_at DESC)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
await db.schema
.dropIndex('idx_pages_space_parent_position')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
await db.schema
.dropIndex('idx_workspaces_hostname_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
await db.schema
.dropIndex('idx_users_workspace_deleted_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_users_workspace_email_lower')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_slug_lower')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_groups_workspace_name_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_key_lower').ifExists().execute();
await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
await db.schema
.dropIndex('idx_comments_parent_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_page_history_page_created')
.ifExists()
.execute();
}
@@ -125,7 +125,6 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_UPDATED, { this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId: updatePageData.workspaceId,
}); });
return result; return result;
@@ -144,7 +143,6 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_CREATED, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id], pageIds: [result.id],
workspaceId: result.workspaceId,
}); });
return result; return result;
@@ -162,11 +160,7 @@ export class PageRepo {
await query.execute(); await query.execute();
} }
async removePage( async removePage(pageId: string, deletedById: string): Promise<void> {
pageId: string,
deletedById: string,
workspaceId: string,
): Promise<void> {
const currentDate = new Date(); const currentDate = new Date();
const descendants = await this.db const descendants = await this.db
@@ -201,15 +195,13 @@ export class PageRepo {
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute(); await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
}); });
this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, { this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId,
}); });
} }
} }
async restorePage(pageId: string, workspaceId: string): Promise<void> { async restorePage(pageId: string): Promise<void> {
// First, check if the page being restored has a deleted parent // First, check if the page being restored has a deleted parent
const pageToRestore = await this.db const pageToRestore = await this.db
.selectFrom('pages') .selectFrom('pages')
@@ -271,7 +263,6 @@ export class PageRepo {
} }
this.eventEmitter.emit(EventName.PAGE_RESTORED, { this.eventEmitter.emit(EventName.PAGE_RESTORED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId: workspaceId,
}); });
} }
@@ -12,15 +12,10 @@ import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class SpaceRepo { export class SpaceRepo {
constructor( constructor(@InjectKysely() private readonly db: KyselyDB) {}
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
) {}
async findById( async findById(
spaceId: string, spaceId: string,
@@ -115,11 +110,7 @@ export class SpaceRepo {
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
eb( eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`, sql`f_unaccent(description)`,
'ilike', 'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`, sql`f_unaccent(${'%' + pagination.query + '%'})`,
@@ -164,9 +155,5 @@ export class SpaceRepo {
.where('id', '=', spaceId) .where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.execute(); .execute();
this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId,
});
} }
} }
@@ -175,22 +175,4 @@ export class WorkspaceRepo {
.returning(this.baseFields) .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
async updateAiSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::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();
}
} }
@@ -1,47 +0,0 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
PageHistory,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -1,20 +0,0 @@
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
// embeddings type
export interface PageEmbeddings {
id: Generated<string>;
pageId: string;
spaceId: string;
modelName: string;
modelDimensions: number;
workspaceId: string;
attachmentId: string;
embedding: number[];
chunkIndex: Generated<number>;
chunkStart: Generated<number>;
chunkLength: Generated<number>;
metadata: Generated<Json>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
@@ -21,7 +21,6 @@ import {
UserMfa as _UserMFA, UserMfa as _UserMFA,
ApiKeys, ApiKeys,
} from './db'; } from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
// Workspace // Workspace
export type Workspace = Selectable<Workspaces>; export type Workspace = Selectable<Workspaces>;
@@ -126,8 +125,3 @@ export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
export type ApiKey = Selectable<ApiKeys>; export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>; export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>; export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
@@ -1,5 +1,5 @@
import { DB } from './db';
import { Kysely, Transaction } from 'kysely'; import { Kysely, Transaction } from 'kysely';
import { DbInterface } from '@docmost/db/types/db.interface';
export type KyselyDB = Kysely<DbInterface>; export type KyselyDB = Kysely<DB>;
export type KyselyTransaction = Transaction<DbInterface>; export type KyselyTransaction = Transaction<DB>;
@@ -10,10 +10,6 @@ export class EnvironmentService {
return this.configService.get<string>('NODE_ENV', 'development'); return this.configService.get<string>('NODE_ENV', 'development');
} }
isDevelopment(): boolean {
return this.getNodeEnv() === 'development';
}
getAppUrl(): string { getAppUrl(): string {
const rawUrl = const rawUrl =
this.configService.get<string>('APP_URL') || this.configService.get<string>('APP_URL') ||
@@ -235,46 +231,6 @@ export class EnvironmentService {
} }
getTypesenseLocale(): string { getTypesenseLocale(): string {
return this.configService return this.configService.get<string>('TYPESENSE_LOCALE', 'en').toLowerCase();
.get<string>('TYPESENSE_LOCALE', 'en')
.toLowerCase();
}
getAiDriver(): string {
return this.configService.get<string>('AI_DRIVER');
}
getAiEmbeddingModel(): string {
return this.configService.get<string>('AI_EMBEDDING_MODEL');
}
getAiCompletionModel(): string {
return this.configService.get<string>('AI_COMPLETION_MODEL');
}
getAiEmbeddingDimension(): number {
return parseInt(
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
10,
);
}
getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY');
}
getOpenAiApiUrl(): string {
return this.configService.get<string>('OPENAI_API_URL');
}
getGeminiApiKey(): string {
return this.configService.get<string>('GEMINI_API_KEY');
}
getOllamaApiUrl(): string {
return this.configService.get<string>(
'OLLAMA_API_URL',
'http://localhost:11434',
);
} }
} }
@@ -93,7 +93,6 @@ export class EnvironmentVariables {
@IsOptional() @IsOptional()
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense') @ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
@IsNotEmpty()
@IsString() @IsString()
TYPESENSE_API_KEY: string; TYPESENSE_API_KEY: string;
@@ -102,53 +101,6 @@ export class EnvironmentVariables {
@IsISO6391() @IsISO6391()
@IsString() @IsString()
TYPESENSE_LOCALE: string; TYPESENSE_LOCALE: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
AI_EMBEDDING_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
@IsString()
@IsNotEmpty()
GEMINI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;
} }
export function validate(config: Record<string, any>) { export function validate(config: Record<string, any>) {
@@ -175,67 +175,6 @@ export class FileImportTaskService {
}); });
} }
// Create placeholder pages for folders without corresponding files
const foldersWithContent = new Set<string>();
pagesMap.forEach((page) => {
const segments = page.filePath.split('/');
segments.pop(); // remove filename
// Build up all folder paths and mark them as having content
let currentPath = '';
for (const segment of segments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
foldersWithContent.add(currentPath); // All ancestor folders have content
}
});
// Determine if there's a single root container folder
const rootLevelItems = new Set<string>();
pagesMap.forEach((page) => {
const firstSegment = page.filePath.split('/')[0];
rootLevelItems.add(firstSegment);
});
// If all files are in a single root folder and no files at root level exist
let skipRootFolder: string | null = null;
if (rootLevelItems.size === 1) {
const onlyRootItem = Array.from(rootLevelItems)[0];
// Check if this is a folder (not a file at root)
const hasRootFiles = Array.from(pagesMap.keys()).some(
(filePath) => !filePath.includes('/'),
);
if (!hasRootFiles) {
skipRootFolder = onlyRootItem;
}
}
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
foldersWithContent.forEach((folderPath) => {
if (
skipRootFolder &&
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
) {
return;
}
const mdPath = `${folderPath}.md`;
const htmlPath = `${folderPath}.html`;
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath);
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
name: stripNotionID(folderName),
content: '',
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
});
}
});
// parent/child linking // parent/child linking
pagesMap.forEach((page, filePath) => { pagesMap.forEach((page, filePath) => {
const segments = filePath.split('/'); const segments = filePath.split('/');
@@ -377,23 +316,10 @@ export class FileImportTaskService {
for (const [filePath, page] of levelPages) { for (const [filePath, page] of levelPages) {
const absPath = path.join(extractDir, filePath); const absPath = path.join(extractDir, filePath);
let content = ''; let content = await fs.readFile(absPath, 'utf-8');
// Check if file exists (placeholder pages won't have physical files) if (page.fileExtension.toLowerCase() === '.md') {
try { content = await markdownToHtml(content);
await fs.access(absPath);
content = await fs.readFile(absPath, 'utf-8');
if (page.fileExtension.toLowerCase() === '.md') {
content = await markdownToHtml(content);
}
} catch (err: any) {
if (err?.code === 'ENOENT') {
// Use empty content, title will be the folder name
content = '';
} else {
throw err;
}
} }
const htmlContent = const htmlContent =
@@ -476,7 +402,6 @@ export class FileImportTaskService {
if (validPageIds.size > 0) { if (validPageIds.size > 0) {
this.eventEmitter.emit(EventName.PAGE_CREATED, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: Array.from(validPageIds), pageIds: Array.from(validPageIds),
workspaceId: fileTask.workspaceId,
}); });
} }
@@ -103,14 +103,6 @@ function extractZipInternal(
zipfile.on('entry', (entry) => { zipfile.on('entry', (entry) => {
const name = entry.fileName.toString('utf8'); const name = entry.fileName.toString('utf8');
const safe = name.replace(/^\/+/, ''); const safe = name.replace(/^\/+/, '');
const validationError = yauzl.validateFileName(safe);
if (validationError) {
console.warn(`Skipping invalid entry (${validationError})`);
zipfile.readEntry();
return;
}
if (safe.startsWith('__MACOSX/')) { if (safe.startsWith('__MACOSX/')) {
zipfile.readEntry(); zipfile.readEntry();
return; return;
@@ -118,15 +110,6 @@ function extractZipInternal(
const fullPath = path.join(target, safe); const fullPath = path.join(target, safe);
const resolved = path.resolve(fullPath);
const targetResolved = path.resolve(target);
if (!resolved.startsWith(targetResolved + path.sep)) {
console.warn(`Skipping entry (path outside target): ${safe}`);
zipfile.readEntry();
return;
}
// Handle directories // Handle directories
if (/\/$/.test(name)) { if (/\/$/.test(name)) {
try { try {
@@ -5,7 +5,6 @@ export enum QueueName {
BILLING_QUEUE = '{billing-queue}', BILLING_QUEUE = '{billing-queue}',
FILE_TASK_QUEUE = '{file-task-queue}', FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}', SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
} }
export enum QueueJob { export enum QueueJob {
@@ -14,6 +13,7 @@ export enum QueueJob {
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content', ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
ATTACHMENT_INDEXING = 'attachment-indexing', ATTACHMENT_INDEXING = 'attachment-indexing',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
PAGE_CONTENT_UPDATE = 'page-content-update',
DELETE_USER_AVATARS = 'delete-user-avatars', DELETE_USER_AVATARS = 'delete-user-avatars',
@@ -39,23 +39,8 @@ export enum QueueJob {
TYPESENSE_FLUSH = 'typesense-flush', TYPESENSE_FLUSH = 'typesense-flush',
PAGE_CREATED = 'page-created', PAGE_CREATED = 'page-created',
PAGE_CONTENT_UPDATED = 'page-content-updated',
PAGE_MOVED_TO_SPACE = 'page-moved-to-space',
PAGE_UPDATED = 'page-updated', PAGE_UPDATED = 'page-updated',
PAGE_SOFT_DELETED = 'page-soft-deleted', PAGE_SOFT_DELETED = 'page-soft-deleted',
PAGE_RESTORED = 'page-restored', PAGE_RESTORED = 'page-restored',
PAGE_DELETED = 'page-deleted', PAGE_DELETED = 'page-deleted',
SPACE_CREATED = 'space-created',
SPACE_UPDATED = 'space-updated',
SPACE_DELETED = 'space-deleted',
WORKSPACE_CREATED = 'workspace-created',
WORKSPACE_SPACE_UPDATED = 'workspace-updated',
WORKSPACE_DELETED = 'workspace-deleted',
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
} }
@@ -65,14 +65,6 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 2, attempts: 2,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.AI_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
], ],
exports: [BullModule], exports: [BullModule],
providers: [BacklinksProcessor], providers: [BacklinksProcessor],
+3 -5
View File
@@ -15,12 +15,10 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,
new FastifyAdapter({ new FastifyAdapter({
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
maxParamLength: 1000,
trustProxy: true, trustProxy: true,
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
},
}), }),
{ {
rawBody: true, rawBody: true,
+41 -43
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.24.1", "version": "0.23.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -21,47 +21,48 @@
"@braintree/sanitize-url": "^7.1.0", "@braintree/sanitize-url": "^7.1.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3", "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.3", "@hocuspocus/extension-redis": "^2.15.2",
"@hocuspocus/provider": "^2.15.3", "@hocuspocus/provider": "^2.15.2",
"@hocuspocus/server": "^2.15.3", "@hocuspocus/server": "^2.15.2",
"@hocuspocus/transformer": "^2.15.3", "@hocuspocus/transformer": "^2.15.2",
"@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",
"@tiptap/core": "2.27.1", "@tiptap/core": "^2.10.3",
"@tiptap/extension-code-block": "2.27.1", "@tiptap/extension-code-block": "^2.10.3",
"@tiptap/extension-code-block-lowlight": "2.27.1", "@tiptap/extension-code-block-lowlight": "^2.10.3",
"@tiptap/extension-collaboration": "2.27.1", "@tiptap/extension-collaboration": "^2.10.3",
"@tiptap/extension-collaboration-cursor": "2.27.1", "@tiptap/extension-collaboration-cursor": "^2.10.3",
"@tiptap/extension-color": "2.27.1", "@tiptap/extension-color": "^2.10.3",
"@tiptap/extension-document": "2.27.1", "@tiptap/extension-document": "^2.10.3",
"@tiptap/extension-heading": "2.27.1", "@tiptap/extension-heading": "^2.10.3",
"@tiptap/extension-highlight": "2.27.1", "@tiptap/extension-highlight": "^2.10.3",
"@tiptap/extension-history": "2.27.1", "@tiptap/extension-history": "^2.10.3",
"@tiptap/extension-image": "2.27.1", "@tiptap/extension-image": "^2.10.3",
"@tiptap/extension-link": "2.27.1", "@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "2.27.1", "@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "2.27.1", "@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-placeholder": "2.27.1", "@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "2.27.1", "@tiptap/extension-subscript": "^2.10.3",
"@tiptap/extension-superscript": "2.27.1", "@tiptap/extension-superscript": "^2.10.3",
"@tiptap/extension-table": "2.27.1", "@tiptap/extension-table": "^2.10.3",
"@tiptap/extension-table-cell": "2.27.1", "@tiptap/extension-table-cell": "^2.10.3",
"@tiptap/extension-table-header": "2.27.1", "@tiptap/extension-table-header": "^2.10.3",
"@tiptap/extension-table-row": "2.27.1", "@tiptap/extension-table-of-contents": "2.26.3",
"@tiptap/extension-task-item": "2.27.1", "@tiptap/extension-table-row": "^2.10.3",
"@tiptap/extension-task-list": "2.27.1", "@tiptap/extension-task-item": "^2.10.3",
"@tiptap/extension-text": "2.27.1", "@tiptap/extension-task-list": "^2.10.3",
"@tiptap/extension-text-align": "2.27.1", "@tiptap/extension-text": "^2.10.3",
"@tiptap/extension-text-style": "2.27.1", "@tiptap/extension-text-align": "^2.10.3",
"@tiptap/extension-typography": "2.27.1", "@tiptap/extension-text-style": "^2.10.3",
"@tiptap/extension-underline": "2.27.1", "@tiptap/extension-typography": "^2.10.3",
"@tiptap/extension-youtube": "2.27.1", "@tiptap/extension-underline": "^2.10.3",
"@tiptap/html": "2.27.1", "@tiptap/extension-youtube": "^2.10.3",
"@tiptap/pm": "2.27.1", "@tiptap/html": "^2.10.3",
"@tiptap/react": "2.27.1", "@tiptap/pm": "^2.10.3",
"@tiptap/starter-kit": "2.27.1", "@tiptap/react": "^2.10.3",
"@tiptap/suggestion": "2.27.1", "@tiptap/starter-kit": "^2.10.3",
"@tiptap/suggestion": "^2.10.3",
"@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",
@@ -76,7 +77,6 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.27" "yjs": "^13.6.27"
}, },
"devDependencies": { "devDependencies": {
@@ -99,9 +99,7 @@
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch" "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
}, },
"overrides": { "overrides": {
"jsdom": "25.0.1", "jsdom": "25.0.1"
"jsonwebtoken": "9.0.3",
"y-prosemirror": "1.3.7"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
} }
+1 -3
View File
@@ -20,6 +20,4 @@ export * from "./lib/markdown";
export * from "./lib/search-and-replace"; export * from "./lib/search-and-replace";
export * from "./lib/embed-provider"; export * from "./lib/embed-provider";
export * from "./lib/subpages"; export * from "./lib/subpages";
export * from "./lib/highlight"; export * from "./lib/table-of-contents-node";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
@@ -1,78 +0,0 @@
import TiptapHeading, {
HeadingOptions as TiptapHeadingOptions,
} from "@tiptap/extension-heading";
import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state";
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>`;
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
const { doc } = state;
doc.descendants((node, pos) => {
if (node.type.name === "heading" && node.content.size > 0) {
const deco = Decoration.widget(
pos + node.nodeSize - 1,
() => {
const icon = document.createElement("span");
icon.classList.add("link-btn");
icon.innerHTML = "&nbsp;";
icon.contentEditable = "false";
const linkBtnContent = document.createElement("span");
linkBtnContent.classList.add("link-btn-content");
linkBtnContent.innerHTML = copyIcon;
icon.appendChild(linkBtnContent);
icon.addEventListener("mousedown", (e) =>
e.preventDefault(),
);
icon.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const id = node.attrs.id;
const baseUrl = window.location.href.split('#')[0];
const url = `${baseUrl}#${id}`;
navigator.clipboard.writeText(url);
linkBtnContent.innerHTML = successIcon;
setTimeout(
() => (linkBtnContent.innerHTML = copyIcon),
2000,
);
});
return icon;
},
{ side: 1 }, // render after node content
);
decorations.push(deco);
}
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
renderHTML({ node, HTMLAttributes }) {
const hasLevel = this.options.levels.includes(node.attrs.level);
const level = hasLevel ? node.attrs.level : this.options.levels[0];
return [
`h${level}`,
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
id: node.attrs.id,
}),
0,
];
},
});
-40
View File
@@ -1,40 +0,0 @@
import {
Highlight as TiptapHighlight,
type HighlightOptions,
} from "@tiptap/extension-highlight";
export const Highlight = TiptapHighlight.extend<HighlightOptions>({
addAttributes() {
return {
...this.parent?.(),
color: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-color") || element.style.backgroundColor,
renderHTML: (attributes) => {
if (!attributes.color) {
return {};
}
return {
"data-color": attributes.color,
style: `background-color: ${attributes.color}; color: inherit`,
};
},
},
colorName: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-highlight-color-name") || null,
renderHTML: (attributes) => {
if (!attributes.colorName) {
return {};
}
return {
"data-highlight-color-name": attributes.colorName.toLowerCase(),
};
},
},
};
},
});
-20
View File
@@ -33,11 +33,6 @@ export interface MentionNodeAttrs {
* the id of the user who initiated the mention * the id of the user who initiated the mention
*/ */
creatorId?: string; creatorId?: string;
/**
* the anchor hash for page mentions (e.g., "heading-1")
*/
anchorId?: string;
} }
export type MentionOptions< export type MentionOptions<
@@ -165,7 +160,6 @@ export const Mention = Node.create<MentionOptions>({
inline: true, inline: true,
selectable: true, selectable: true,
atom: true, atom: true,
draggable: true,
addAttributes() { addAttributes() {
return { return {
@@ -252,20 +246,6 @@ export const Mention = Node.create<MentionOptions>({
}; };
}, },
}, },
anchorId: {
default: null,
parseHTML: (element) => element.getAttribute("data-anchor-id"),
renderHTML: (attributes) => {
if (!attributes.anchorId) {
return {};
}
return {
"data-anchor-id": attributes.anchorId,
};
},
},
}; };
}, },
@@ -28,7 +28,7 @@ export const Subpages = Node.create<SubpagesOptions>({
group: "block", group: "block",
atom: true, atom: true,
draggable: true, draggable: false,
parseHTML() { parseHTML() {
return [ return [
@@ -0,0 +1 @@
export { TableOfContentsNode } from "./table-of-contents";
@@ -0,0 +1,52 @@
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface TableOfContentsNodeOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
tableOfContentsNode: {
insertTableOfContents: () => ReturnType;
};
}
}
export const TableOfContentsNode = Node.create<TableOfContentsNodeOptions>({
name: "tableOfContentsNode",
group: "block",
atom: true,
selectable: true,
draggable: true,
inline: false,
parseHTML() {
return [
{
tag: 'div[data-type="table-of-content"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, "data-type": "table-of-content" }];
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
insertTableOfContents:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
});
},
};
},
});
@@ -64,12 +64,6 @@ export const TrailingNode = Extension.create<TrailingNodeExtensionOptions>({
return value return value
} }
// Ignore transactions from UniqueID extension to prevent infinite loops
// when UniqueID adds IDs to newly inserted trailing nodes
if (tr.getMeta('__uniqueIDTransaction')) {
return value
}
const lastNode = tr.doc.lastChild const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes }) return !nodeEqualsType({ node: lastNode, types: disabledNodes })
}, },
@@ -1,11 +0,0 @@
import { removeDuplicates } from './removeDuplicates.js'
/**
* Returns a list of duplicated items within an array.
*/
export function findDuplicates(items: any[]): any[] {
const filtered = items.filter((el, index) => items.indexOf(el) !== index)
const duplicates = removeDuplicates(filtered)
return duplicates
}

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