mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea27919079 | |||
| 2b6ac81c61 |
+2
-4
@@ -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"
|
||||
|
||||
FROM base AS builder
|
||||
@@ -13,9 +13,7 @@ RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache curl bash
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.24.1",
|
||||
"version": "0.23.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@tiptap/extension-character-count": "^2.10.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -57,7 +57,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
@@ -65,10 +65,10 @@
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/node": "22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
@@ -81,6 +81,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"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.",
|
||||
"Description": "Beschreibung",
|
||||
"Details": "Details",
|
||||
"Details": "Einzelheiten",
|
||||
"e.g ACME": "z.B. ACME",
|
||||
"e.g ACME Inc": "z.B. ACME Inc.",
|
||||
"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.",
|
||||
"Invite link": "Einladungslink",
|
||||
"Copy": "Kopieren",
|
||||
"Copy to space": "In Raum kopieren",
|
||||
"Copied": "Kopiert",
|
||||
"Duplicate": "Duplizieren",
|
||||
"Select a user": "Benutzer auswählen",
|
||||
"Select a group": "Gruppe auswählen",
|
||||
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
|
||||
@@ -525,47 +527,5 @@
|
||||
"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?",
|
||||
"Action": "Aktion",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
|
||||
}
|
||||
|
||||
@@ -554,20 +554,5 @@
|
||||
"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.",
|
||||
"Update API key": "Update API key",
|
||||
"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"
|
||||
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"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?",
|
||||
"Action": "Acción",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"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 ?",
|
||||
"Action": "Action",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "Elimina provider SSO",
|
||||
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
|
||||
"Action": "Azione",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "SSOプロバイダーを削除する",
|
||||
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
|
||||
"Action": "アクション",
|
||||
"{{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": "色を削除"
|
||||
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "SSO 제공자 삭제",
|
||||
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
|
||||
"Action": "작업",
|
||||
"{{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": "색 제거"
|
||||
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"Create group": "Groep aanmaken",
|
||||
"Create page": "Pagina aanmaken",
|
||||
"Create space": "Ruimte aanmaken",
|
||||
"Create workspace": "Werkruimte aanmaken",
|
||||
"Create workspace": "Wwerkruimte aanmaken",
|
||||
"Current password": "Huidig wachtwoord",
|
||||
"Dark": "Donker",
|
||||
"Date": "Datum",
|
||||
@@ -91,7 +91,7 @@
|
||||
"Invite by email": "Uitnodigen via e-mail",
|
||||
"Invite members": "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",
|
||||
"Join the workspace": "Word lid van de werkruimte",
|
||||
"Language": "Taal",
|
||||
@@ -527,47 +527,5 @@
|
||||
"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?",
|
||||
"Action": "Actie",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"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?",
|
||||
"Action": "Ação",
|
||||
"{{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"
|
||||
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -498,10 +498,10 @@
|
||||
"Deleted at": "Удалено в",
|
||||
"Preview": "Предпросмотр",
|
||||
"Subpages": "Подстраницы",
|
||||
"Failed to load subpages": "Не удалось загрузить под страницы",
|
||||
"Failed to load subpages": "Не удалось загрузить подстраницы",
|
||||
"No subpages": "Нет подстраниц",
|
||||
"Subpages (Child pages)": "Подстраницы (вложенные страницы)",
|
||||
"List all subpages of the current page": "Показать все под страницы",
|
||||
"List all subpages of the current page": "Показать все подстраницы текущей страницы",
|
||||
"Attachments": "Вложения",
|
||||
"All spaces": "Все пространства",
|
||||
"Unknown": "Неизвестно",
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "Удалить поставщика SSO",
|
||||
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
|
||||
"Action": "Действие",
|
||||
"{{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": "Удалить цвет"
|
||||
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "Видалити постачальника SSO",
|
||||
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
|
||||
"Action": "Дія",
|
||||
"{{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": "Видалити колір"
|
||||
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
|
||||
}
|
||||
|
||||
@@ -527,47 +527,5 @@
|
||||
"Delete SSO provider": "删除SSO提供商",
|
||||
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
|
||||
"Action": "操作",
|
||||
"{{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": "移除颜色"
|
||||
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-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() {
|
||||
const { t } = useTranslation();
|
||||
@@ -108,7 +107,6 @@ export default function App() {
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -12,14 +12,13 @@ import {
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
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 {
|
||||
prefetchApiKeyManagement,
|
||||
@@ -110,13 +109,6 @@ const groupedData: DataGroup[] = [
|
||||
isAdmin: 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,
|
||||
};
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<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.Tbody>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -144,16 +144,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
onHide: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps}>
|
||||
@@ -164,8 +164,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsTextAlignmentOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -175,8 +175,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
setIsOpen={() => {
|
||||
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Dispatch, FC, SetStateAction } from "react";
|
||||
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
SimpleGrid,
|
||||
Box,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
@@ -64,12 +61,9 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||
name: "Gray",
|
||||
color: "#A8A29E",
|
||||
},
|
||||
{
|
||||
name: "Brown",
|
||||
color: "#92400E",
|
||||
},
|
||||
];
|
||||
|
||||
// TODO: handle dark mode
|
||||
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: "Default",
|
||||
@@ -77,39 +71,35 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
},
|
||||
{
|
||||
name: "Blue",
|
||||
color: "#98d8f2",
|
||||
color: "#c1ecf9",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
color: "#7edb6c",
|
||||
color: "#acf79f",
|
||||
},
|
||||
{
|
||||
name: "Purple",
|
||||
color: "#e0d6ed",
|
||||
color: "#f6f3f8",
|
||||
},
|
||||
{
|
||||
name: "Red",
|
||||
color: "#ffc6c2",
|
||||
color: "#fdebeb",
|
||||
},
|
||||
{
|
||||
name: "Yellow",
|
||||
color: "#faf594",
|
||||
color: "#fbf4a2",
|
||||
},
|
||||
{
|
||||
name: "Orange",
|
||||
color: "#f5c8a9",
|
||||
color: "#faebdd",
|
||||
},
|
||||
{
|
||||
name: "Pink",
|
||||
color: "#f5cfe0",
|
||||
color: "#faf1f5",
|
||||
},
|
||||
{
|
||||
name: "Gray",
|
||||
color: "#dfdfd7",
|
||||
},
|
||||
{
|
||||
name: "Brown",
|
||||
color: "#d7c4b7",
|
||||
color: "#f1f1ef",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -122,21 +112,17 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
selector: ctx => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeColors: Record<string, boolean> = {};
|
||||
TEXT_COLORS.forEach(({ color }) => {
|
||||
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", {
|
||||
color,
|
||||
});
|
||||
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
|
||||
});
|
||||
HIGHLIGHT_COLORS.forEach(({ color }) => {
|
||||
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", {
|
||||
color,
|
||||
});
|
||||
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
|
||||
});
|
||||
|
||||
return activeColors;
|
||||
@@ -147,152 +133,67 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeColorItem = TEXT_COLORS.find(
|
||||
({ color }) => editorState[`text_${color}`],
|
||||
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||
editorState[`text_${color}`]
|
||||
);
|
||||
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(
|
||||
({ color }) => editorState[`highlight_${color}`],
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||
editorState[`highlight_${color}`]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover width={220} opened={isOpen} withArrow>
|
||||
<Popover width={200} opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text color")} withArrow>
|
||||
<Button
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="0"
|
||||
rightSection={<IconChevronDown size={16} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
data-text-color={activeColorItem?.color || ""}
|
||||
data-highlight-color={activeHighlightItem?.color || ""}
|
||||
className="color-selector-trigger"
|
||||
style={{
|
||||
height: "34px",
|
||||
border: "none",
|
||||
fontWeight: 500,
|
||||
fontSize: rem(16),
|
||||
paddingLeft: rem(8),
|
||||
paddingRight: rem(4),
|
||||
color: activeColorItem?.color,
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
A
|
||||
</Button>
|
||||
<IconPalette size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
{/* make mah responsive */}
|
||||
<ScrollArea.Autosize type="scroll" mah="400">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{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>
|
||||
<Text span c="dimmed" tt="uppercase" inherit>
|
||||
{t("Color")}
|
||||
</Text>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Highlight color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
||||
<Tooltip key={index} label={t(name)} withArrow>
|
||||
<Box
|
||||
onClick={() => {
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleMark("highlight", {
|
||||
color: color || "",
|
||||
colorName: name.toLowerCase() || "",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: rem(28),
|
||||
height: rem(28),
|
||||
borderRadius: rem(4),
|
||||
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>
|
||||
<Button.Group orientation="vertical">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="default"
|
||||
leftSection={<span style={{ color }}>A</span>}
|
||||
justify="left"
|
||||
fullWidth
|
||||
rightSection={
|
||||
editorState[`text_${color}`] && (
|
||||
<IconCheck style={{ width: rem(16) }} />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor.chain().focus().setColor(color || "").run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
{t(name)}
|
||||
</Button>
|
||||
))}
|
||||
</Button.Group>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
@@ -38,7 +37,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
|
||||
.catch((err) => {
|
||||
if (props.editor.isEditable) {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`,
|
||||
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
|
||||
);
|
||||
} else {
|
||||
setPreview(
|
||||
|
||||
@@ -34,9 +34,7 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
||||
createMentionAction(url, view, pos, creatorId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
{embedUrl ? (
|
||||
<ResizableWrapper
|
||||
initialHeight={nodeHeight || 480}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<ReactClearModal
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
}, [align]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
|
||||
@@ -9,7 +9,6 @@ export type LinkFn = (
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
creatorId: string,
|
||||
anchorId?: string,
|
||||
) => void;
|
||||
|
||||
export interface InternalLinkOptions {
|
||||
@@ -19,7 +18,7 @@ export interface InternalLinkOptions {
|
||||
|
||||
export const handleInternalLink =
|
||||
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
|
||||
async (url: string, view, pos, creatorId, anchorId) => {
|
||||
async (url: string, view, pos, creatorId) => {
|
||||
const validated = validateFn(url, view);
|
||||
if (!validated) return;
|
||||
|
||||
@@ -36,7 +35,6 @@ export const handleInternalLink =
|
||||
entityId: page.id,
|
||||
slugId: page.slugId,
|
||||
creatorId: creatorId,
|
||||
anchorId: anchorId,
|
||||
});
|
||||
|
||||
if (!node) return;
|
||||
|
||||
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
const { label, entityType, entityId, slugId } = node.attrs;
|
||||
const { spaceSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const {
|
||||
@@ -27,11 +27,10 @@ export default function MentionView(props: NodeViewProps) {
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: label,
|
||||
anchorId,
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<NodeViewWrapper style={{ display: "inline" }}>
|
||||
{entityType === "user" && (
|
||||
<Text className={classes.userMention} component="span">
|
||||
@{label}
|
||||
@@ -43,7 +42,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
|
||||
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||
}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconAlignLeft2,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -153,6 +154,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
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",
|
||||
description: "Upload any image from your device.",
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
|
||||
if (error && !shareId) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
@@ -62,7 +62,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
|
||||
if (subpages.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
@@ -73,7 +73,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{subpages.map((page) => (
|
||||
|
||||
+61
@@ -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));
|
||||
}
|
||||
+55
@@ -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]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<NodeViewWrapper>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { TaskList } from "@tiptap/extension-task-list";
|
||||
import { ListKeymap } from "@tiptap/extension-list-keymap";
|
||||
import { TaskItem } from "@tiptap/extension-task-item";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import SubScript from "@tiptap/extension-subscript";
|
||||
import { Highlight } from "@tiptap/extension-highlight";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
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 { 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 { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import {
|
||||
@@ -43,9 +41,7 @@ import {
|
||||
Mention,
|
||||
Subpages,
|
||||
TableDndExtension,
|
||||
Heading,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
TableOfContentsNode,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -54,8 +50,11 @@ import {
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.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 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 AttachmentView from "@/features/editor/components/attachment/attachment-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 EmbedView from "@/features/editor/components/embed/embed-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 powershell from "highlight.js/lib/languages/powershell";
|
||||
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 { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
import { countWords } from "alfaaz";
|
||||
import { TableOfContentsNodeview } from "@/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -96,7 +96,6 @@ lowlight.register("scala", scala);
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
history: false,
|
||||
dropcursor: {
|
||||
width: 3,
|
||||
@@ -109,11 +108,6 @@ export const mainExtensions = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
@@ -134,7 +128,6 @@ export const mainExtensions = [
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
ListKeymap,
|
||||
Underline,
|
||||
LinkExtension.configure({
|
||||
openOnClick: false,
|
||||
@@ -251,6 +244,10 @@ export const mainExtensions = [
|
||||
};
|
||||
},
|
||||
}).configure(),
|
||||
TiptapTableOfContents,
|
||||
TableOfContentsNode.configure({
|
||||
view: TableOfContentsNodeview,
|
||||
}),
|
||||
] as 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 React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
@@ -56,7 +56,6 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -69,16 +68,7 @@ export default function PageEditor({
|
||||
editable,
|
||||
content,
|
||||
}: PageEditorProps) {
|
||||
|
||||
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
}, []);
|
||||
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setEditor] = useAtom(pageEditorAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
@@ -104,9 +94,7 @@ export default function PageEditor({
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const userPageEditMode =
|
||||
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
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
@@ -276,8 +264,6 @@ export default function PageEditor({
|
||||
// @ts-ignore
|
||||
setEditor(editor);
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
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 { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
@@ -22,34 +21,9 @@ export default function ReadonlyPageEditor({
|
||||
pageId,
|
||||
}: PageEditorProps) {
|
||||
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 filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
updateDocument: false,
|
||||
}),
|
||||
];
|
||||
return [...mainExtensions];
|
||||
}, []);
|
||||
|
||||
const titleExtensions = [
|
||||
@@ -85,9 +59,6 @@ export default function ReadonlyPageEditor({
|
||||
}
|
||||
// @ts-ignore
|
||||
setReadOnlyEditor(editor);
|
||||
|
||||
handleScrollTo(editor);
|
||||
editorCreated.current = true;
|
||||
}
|
||||
}}
|
||||
></EditorProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-default-color),
|
||||
var(--mantine-color-white)
|
||||
var(--mantine-color-dark-0)
|
||||
);
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-xl);
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
& > .react-renderer {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
|
||||
&:first-child {
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
.selection,
|
||||
*::selection {
|
||||
background-color: light-dark(Highlight, var(--mantine-color-gray-7));
|
||||
background-color: Highlight;
|
||||
}
|
||||
|
||||
.comment-mark {
|
||||
@@ -186,39 +186,6 @@
|
||||
margin-left: 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 {
|
||||
@@ -242,3 +209,4 @@
|
||||
.actionIconGroup {
|
||||
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 "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
|
||||
@@ -104,10 +104,7 @@ export function TitleEditor({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
||||
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
@@ -195,43 +192,10 @@ export function TitleEditor({
|
||||
const { key } = event;
|
||||
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 =
|
||||
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter);
|
||||
key === "Enter" ||
|
||||
key === "ArrowDown" ||
|
||||
(key === "ArrowRight" && !$head.nodeAfter);
|
||||
|
||||
if (shouldFocusEditor) {
|
||||
pageEditor.commands.focus("start");
|
||||
|
||||
@@ -15,29 +15,22 @@ export const buildPageUrl = (
|
||||
spaceName: string,
|
||||
pageSlugId: string,
|
||||
pageTitle?: string,
|
||||
anchorId?: string,
|
||||
): string => {
|
||||
let url: string;
|
||||
if (spaceName === undefined) {
|
||||
url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
};
|
||||
|
||||
export const buildSharedPageUrl = (opts: {
|
||||
shareId: string;
|
||||
pageSlugId: string;
|
||||
pageTitle?: string;
|
||||
anchorId?: string;
|
||||
}): string => {
|
||||
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
|
||||
let url: string;
|
||||
const { shareId, pageSlugId, pageTitle } = opts;
|
||||
if (!shareId) {
|
||||
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
} else {
|
||||
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
}
|
||||
return anchorId ? `${url}#${anchorId}` : url;
|
||||
|
||||
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ScrollArea,
|
||||
Avatar,
|
||||
Group,
|
||||
Switch,
|
||||
getDefaultZIndex,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
IconFileDescription,
|
||||
IconSearch,
|
||||
IconCheck,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 classes from "./search-spotlight-filters.module.css";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
interface SearchSpotlightFiltersProps {
|
||||
onFiltersChange?: (filters: any) => void;
|
||||
onAskClick?: () => void;
|
||||
spaceId?: string;
|
||||
isAiMode?: boolean;
|
||||
}
|
||||
|
||||
export function SearchSpotlightFilters({
|
||||
onFiltersChange,
|
||||
onAskClick,
|
||||
spaceId,
|
||||
isAiMode = false,
|
||||
}: SearchSpotlightFiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
@@ -50,7 +42,6 @@ export function SearchSpotlightFilters({
|
||||
const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
|
||||
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
|
||||
const [contentType, setContentType] = useState<string | null>("page");
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
|
||||
const { data: spacesData } = useGetSpacesQuery({
|
||||
page: 1,
|
||||
@@ -129,31 +120,6 @@ export function SearchSpotlightFilters({
|
||||
|
||||
return (
|
||||
<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
|
||||
shadow="md"
|
||||
width={250}
|
||||
@@ -265,7 +231,7 @@ export function SearchSpotlightFilters({
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
disabled={option.disabled || (isAiMode && option.value === "attachment")}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
@@ -275,11 +241,6 @@ export function SearchSpotlightFilters({
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
)}
|
||||
{!option.disabled && isAiMode && option.value === "attachment" && (
|
||||
<Text size="xs" mt={4}>
|
||||
{t("Ask AI not available for attachments")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} />}
|
||||
</Group>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Spotlight } from "@mantine/spotlight";
|
||||
import { IconSearch, IconSparkles } from "@tabler/icons-react";
|
||||
import { Group, Button } from "@mantine/core";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { searchSpotlightStore } from "../constants.ts";
|
||||
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
||||
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 { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||
import { useLicense } from "@/ee/hooks/use-license.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
@@ -28,7 +24,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
}>({
|
||||
contentType: "page",
|
||||
});
|
||||
const [isAiMode, setIsAiMode] = useState(false);
|
||||
|
||||
// Build unified search params
|
||||
const searchParams = useMemo(() => {
|
||||
@@ -45,42 +40,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
return params;
|
||||
}, [debouncedSearchQuery, filters]);
|
||||
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(
|
||||
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]);
|
||||
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
||||
|
||||
// Determine result type for rendering
|
||||
const isAttachmentSearch =
|
||||
@@ -99,16 +59,6 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleAskClick = () => {
|
||||
setIsAiMode(!isAiMode);
|
||||
};
|
||||
|
||||
const handleAiSearchTrigger = () => {
|
||||
if (query.trim() && isAiMode) {
|
||||
triggerAiSearchMutation(searchParams);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spotlight.Root
|
||||
@@ -122,30 +72,10 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
backgroundOpacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" px="sm" pt="sm" pb="xs">
|
||||
<Spotlight.Search
|
||||
placeholder={isAiMode ? t("Ask a question...") : t("Search...")}
|
||||
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>
|
||||
<Spotlight.Search
|
||||
placeholder={t("Search...")}
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -154,43 +84,20 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
>
|
||||
<SearchSpotlightFilters
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onAskClick={handleAskClick}
|
||||
spaceId={spaceId}
|
||||
isAiMode={isAiMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spotlight.ActionsList>
|
||||
{isAiMode ? (
|
||||
<>
|
||||
{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 && 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}</>}
|
||||
</Spotlight.ActionsList>
|
||||
</Spotlight.Root>
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
|
||||
|
||||
export function useUnifiedSearch(
|
||||
params: UseUnifiedSearchParams,
|
||||
enabled: boolean = true,
|
||||
): UseQueryResult<UnifiedSearchResult[], Error> {
|
||||
const { hasLicenseKey } = useLicense();
|
||||
|
||||
@@ -39,6 +38,6 @@ export function useUnifiedSearch(
|
||||
return await searchPage(backendParams);
|
||||
}
|
||||
},
|
||||
enabled: !!params.query && enabled,
|
||||
enabled: !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,17 +62,14 @@ export default function SpaceSettingsModal({
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
||||
<div style={{ paddingBottom: "100px"}}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea h={550} scrollbarSize={4} pr={8}>
|
||||
<SpaceDetails
|
||||
spaceId={space?.id}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<ScrollArea h={450}>
|
||||
<ScrollArea h={400}>
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing={8}>
|
||||
<Table.Thead>
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(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);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IWorkspace {
|
||||
defaultSpaceId: string;
|
||||
customDomain: string;
|
||||
enableInvite: boolean;
|
||||
settings: IWorkspaceSettings;
|
||||
settings: any;
|
||||
status: string;
|
||||
enforceSso: boolean;
|
||||
stripeCustomerId: string;
|
||||
@@ -24,14 +24,6 @@ export interface IWorkspace {
|
||||
enforceMfa?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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;
|
||||
@@ -51,7 +51,7 @@ root.render(
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<Notifications position="bottom-center" limit={3} />
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
|
||||
@@ -21,7 +21,6 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
|
||||
export default function Page() {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
|
||||
+18
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.24.1",
|
||||
"version": "0.23.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -30,9 +30,6 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"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/lib-storage": "3.701.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.701.0",
|
||||
@@ -40,55 +37,51 @@
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.9",
|
||||
"@nestjs/platform-socket.io": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.3",
|
||||
"@nestjs/platform-socket.io": "^11.1.3",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.9",
|
||||
"@nestjs/websockets": "^11.1.3",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^5.0.65",
|
||||
"ai-sdk-ollama": "^0.12.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.65.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.61.0",
|
||||
"cache-manager": "^6.4.3",
|
||||
"cheerio": "^1.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"fs-extra": "^11.3.0",
|
||||
"happy-dom": "20.0.10",
|
||||
"happy-dom": "^18.0.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "^0.28.2",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"ldapts": "^7.4.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mammoth": "^1.10.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.11",
|
||||
"nestjs-kysely": "^1.2.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"nodemailer": "^7.0.3",
|
||||
"openid-client": "^5.7.1",
|
||||
"otpauth": "^9.4.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"pg": "^8.16.3",
|
||||
"pdfjs-dist": "^5.4.54",
|
||||
"pg": "^8.16.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -99,7 +92,7 @@
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.18.3",
|
||||
"ws": "^8.18.2",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { TaskItem } from '@tiptap/extension-task-item';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Superscript } from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { Highlight } from '@tiptap/extension-highlight';
|
||||
import { Typography } from '@tiptap/extension-typography';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import { Youtube } from '@tiptap/extension-youtube';
|
||||
import {
|
||||
Heading,
|
||||
Callout,
|
||||
Comment,
|
||||
CustomCodeBlock,
|
||||
@@ -33,9 +33,7 @@ import {
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
Highlight,
|
||||
UniqueID,
|
||||
addUniqueIdsToDoc,
|
||||
TableOfContentsNode,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -47,11 +45,6 @@ import { Node } from '@tiptap/pm/model';
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
heading: false,
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Comment,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
@@ -88,6 +81,7 @@ export const tiptapExtensions = [
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
TableOfContentsNode,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
@@ -95,14 +89,7 @@ export function jsonToHtml(tiptapJson: any) {
|
||||
}
|
||||
|
||||
export function htmlToJson(html: string) {
|
||||
const pmJson = generateJSON(html, tiptapExtensions);
|
||||
|
||||
try {
|
||||
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
|
||||
} catch (error) {
|
||||
console.warn('failed to add unique ids to doc', error);
|
||||
return pmJson;
|
||||
}
|
||||
return generateJSON(html, tiptapExtensions);
|
||||
}
|
||||
|
||||
export function jsonToText(tiptapJson: JSONContent) {
|
||||
|
||||
@@ -35,7 +35,6 @@ export class PersistenceExtension implements Extension {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -169,11 +168,6 @@ export class PersistenceExtension implements Extension {
|
||||
workspaceId: page.workspaceId,
|
||||
mentions: pageMentions,
|
||||
} as IPageBacklinkJob);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
workspaceId: page.workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
export enum EventName {
|
||||
COLLAB_PAGE_UPDATED = 'collab.page.updated',
|
||||
|
||||
PAGE_CREATED = 'page.created',
|
||||
PAGE_UPDATED = 'page.updated',
|
||||
PAGE_CONTENT_UPDATED = 'page-content-updated',
|
||||
PAGE_MOVED_TO_SPACE = 'page-moved-to-space',
|
||||
PAGE_DELETED = 'page.deleted',
|
||||
PAGE_SOFT_DELETED = 'page.soft_deleted',
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
|
||||
|
||||
const slugIdAlphabet =
|
||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
@@ -10,12 +10,6 @@ export enum SpaceRole {
|
||||
READER = 'reader', // can only read pages in space
|
||||
}
|
||||
|
||||
export enum PageRole {
|
||||
WRITER = 'writer', // can read and write pages in space
|
||||
READER = 'reader', // can only read pages in space
|
||||
RESTRICTED = 'restricted', // cannot access page
|
||||
}
|
||||
|
||||
export enum SpaceVisibility {
|
||||
OPEN = 'open', // any workspace member can see that it exists and join.
|
||||
PRIVATE = 'private', // only added space users can see
|
||||
|
||||
@@ -14,18 +14,11 @@ export class InternalLogFilter extends ConsoleLogger {
|
||||
super();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||
|
||||
|
||||
if (isProduction && !isDebugMode) {
|
||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
||||
} else {
|
||||
this.allowedLogLevels = [
|
||||
'log',
|
||||
'debug',
|
||||
'verbose',
|
||||
'warn',
|
||||
'error',
|
||||
'fatal',
|
||||
];
|
||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
AbilityBuilder,
|
||||
createMongoAbility,
|
||||
MongoAbility,
|
||||
} from '@casl/ability';
|
||||
import { PageRole, SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
PagePermissionRepo,
|
||||
PageMemberRole,
|
||||
} from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import {
|
||||
PageCaslAction,
|
||||
IPageAbility,
|
||||
PageCaslSubject,
|
||||
} from '../interfaces/page-ability.type';
|
||||
import { findHighestUserSpaceRole } from '@docmost/db/repos/Space/utils';
|
||||
import { UserSpaceRole } from '@docmost/db/repos/space/types';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export default class PageAbilityFactory {
|
||||
private readonly logger = new Logger(PageAbilityFactory.name);
|
||||
|
||||
constructor(
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async createForUser(user: User, pageId: string) {
|
||||
//user.id = '0197750c-a70c-73a6-83ad-65a193433f5c';
|
||||
|
||||
// This opens the possibility to share pages with individual users from other Spaces
|
||||
|
||||
/*
|
||||
//TODO: we might account for space permission here too.
|
||||
// we could just do it all here. no need to call two abilities.
|
||||
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||
user.id,
|
||||
spaceId,
|
||||
);
|
||||
*/
|
||||
|
||||
// const userPageRole = findHighestUserPageRole(userPageRoles);
|
||||
// if no role abort
|
||||
|
||||
// Check page-level permissions first if pageId provided
|
||||
|
||||
const permission = await this.pagePermissionRepo.getUserPagePermission({
|
||||
pageId: pageId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// does it pick one? what if the user has permissions via groups? what roles takes precedence?
|
||||
|
||||
if (!permission) {
|
||||
//TODO: it means we should use the space level permission
|
||||
// need deeper understanding here though
|
||||
// call the space factory?
|
||||
}
|
||||
|
||||
this.logger.log('permissions', permission);
|
||||
if (permission) {
|
||||
// make sure the permission is for this page
|
||||
// or cascaded/inherited from a parent page
|
||||
/*this.logger.debug('role', permission.role, 'cascade', permission.cascade);
|
||||
if (permission.pageId !== pageId && !permission.cascade) {
|
||||
this.logger.debug('no permission');
|
||||
// No explicit access and not inheriting - deny
|
||||
return new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||
createMongoAbility,
|
||||
).build();
|
||||
}*/
|
||||
}
|
||||
|
||||
// if no permission should we use space permission here?
|
||||
// if non, skip for default to take precedence
|
||||
|
||||
switch (permission.role) {
|
||||
case PageRole.WRITER:
|
||||
return buildPageWriterAbility();
|
||||
case PageRole.READER:
|
||||
return buildPageReaderAbility();
|
||||
case PageRole.RESTRICTED:
|
||||
return buildPageRestrictedAbility();
|
||||
default:
|
||||
throw new NotFoundException('Page permissions not found');
|
||||
}
|
||||
}
|
||||
|
||||
private buildAbilityForRole(role: string) {
|
||||
switch (role) {
|
||||
case PageRole.WRITER:
|
||||
return buildPageWriterAbility();
|
||||
case PageRole.READER:
|
||||
return buildPageReaderAbility();
|
||||
case PageRole.RESTRICTED:
|
||||
return buildPageRestrictedAbility();
|
||||
default:
|
||||
return new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||
createMongoAbility,
|
||||
).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPageWriterAbility() {
|
||||
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||
createMongoAbility,
|
||||
);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Member);
|
||||
can(PageCaslAction.Manage, PageCaslSubject.Page);
|
||||
can(PageCaslAction.Manage, PageCaslSubject.Share);
|
||||
return build();
|
||||
}
|
||||
|
||||
function buildPageReaderAbility() {
|
||||
const { can, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||
createMongoAbility,
|
||||
);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Member);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Page);
|
||||
can(PageCaslAction.Read, PageCaslSubject.Share);
|
||||
return build();
|
||||
}
|
||||
|
||||
function buildPageRestrictedAbility() {
|
||||
const { cannot, build } = new AbilityBuilder<MongoAbility<IPageAbility>>(
|
||||
createMongoAbility,
|
||||
);
|
||||
cannot(PageCaslAction.Read, PageCaslSubject.Settings);
|
||||
cannot(PageCaslAction.Read, PageCaslSubject.Member);
|
||||
cannot(PageCaslAction.Read, PageCaslSubject.Page);
|
||||
cannot(PageCaslAction.Read, PageCaslSubject.Share);
|
||||
return build();
|
||||
}
|
||||
|
||||
export interface UserPageRole {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function findHighestUserPageRole(userPageRoles: UserPageRole[]) {
|
||||
//TODO: perhaps, we want the lowest here?
|
||||
if (!userPageRoles) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const roleOrder: { [key in PageRole]: number } = {
|
||||
[PageRole.WRITER]: 3,
|
||||
[PageRole.READER]: 2,
|
||||
[PageRole.RESTRICTED]: 1,
|
||||
};
|
||||
let highestRole: string;
|
||||
|
||||
for (const userPageRole of userPageRoles) {
|
||||
const currentRole = userPageRole.role;
|
||||
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
|
||||
highestRole = currentRole;
|
||||
}
|
||||
}
|
||||
return highestRole;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import SpaceAbilityFactory from './abilities/space-ability.factory';
|
||||
import WorkspaceAbilityFactory from './abilities/workspace-ability.factory';
|
||||
import PageAbilityFactory from './abilities/page-ability.factory';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory, PageAbilityFactory],
|
||||
providers: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||
exports: [WorkspaceAbilityFactory, SpaceAbilityFactory],
|
||||
})
|
||||
export class CaslModule {}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export enum PageCaslAction {
|
||||
Manage = 'manage',
|
||||
Create = 'create',
|
||||
Read = 'read',
|
||||
Edit = 'edit',
|
||||
Delete = 'delete',
|
||||
}
|
||||
export enum PageCaslSubject {
|
||||
Settings = 'settings',
|
||||
Member = 'member',
|
||||
Page = 'page',
|
||||
Share = 'share',
|
||||
}
|
||||
|
||||
export type IPageAbility =
|
||||
| [PageCaslAction, PageCaslSubject.Settings]
|
||||
| [PageCaslAction, PageCaslSubject.Member]
|
||||
| [PageCaslAction, PageCaslSubject.Page]
|
||||
| [PageCaslAction, PageCaslSubject.Share];
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
|
||||
export class CreateGroupDto {
|
||||
@MinLength(2)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
|
||||
export class AddPageMembersDto extends PageIdDto {
|
||||
@IsEnum(PageMemberRole)
|
||||
role: string;
|
||||
// optional
|
||||
@IsArray()
|
||||
@ArrayMaxSize(25, {
|
||||
message: 'userIds must be an array with no more than 25 elements',
|
||||
})
|
||||
@IsUUID('all', { each: true })
|
||||
userIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(25, {
|
||||
message: 'groupIds must be an array with no more than 25 elements',
|
||||
})
|
||||
@IsUUID('all', { each: true })
|
||||
groupIds: string[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
cascade?: boolean; // Apply to all child pages
|
||||
}
|
||||
@@ -4,4 +4,4 @@ export class DeletedPageDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export type CopyPageMapEntry = {
|
||||
};
|
||||
|
||||
export type ICopyPageAttachment = {
|
||||
newPageId: string;
|
||||
oldPageId: string;
|
||||
oldAttachmentId: string;
|
||||
newAttachmentId: string;
|
||||
newPageId: string,
|
||||
oldPageId: string,
|
||||
oldAttachmentId: string,
|
||||
newAttachmentId: string,
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class GetPageMembersDto extends PageIdDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query?: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
|
||||
export class RemovePageMemberDto extends PageIdDto {
|
||||
@IsUUID()
|
||||
memberId: string;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsEnum, IsUUID } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
|
||||
export class UpdatePageMemberRoleDto extends PageIdDto {
|
||||
@IsUUID()
|
||||
memberId: string;
|
||||
|
||||
@IsEnum(PageMemberRole)
|
||||
role: string;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { IsBoolean, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
import { PageMemberRole } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
|
||||
export class UpdatePagePermissionDto {
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@IsEnum(PageMemberRole)
|
||||
role: string;
|
||||
|
||||
@IsBoolean()
|
||||
cascade: boolean; // Apply to all child pages
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||
import {
|
||||
DeletePageDto,
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
DeletePageDto,
|
||||
} from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@@ -32,24 +32,9 @@ import {
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { AddPageMembersDto } from './dto/add-page-members.dto';
|
||||
import { RemovePageMemberDto } from './dto/remove-page-member.dto';
|
||||
import { UpdatePageMemberRoleDto } from './dto/update-page-member-role.dto';
|
||||
import { UpdatePagePermissionDto } from './dto/update-page-permission.dto';
|
||||
import { GetPageMembersDto } from './dto/get-page-members.dto';
|
||||
import {
|
||||
PagePermissionService,
|
||||
PagePermissionsResponse,
|
||||
} from './services/page-member.service';
|
||||
import PageAbilityFactory from '../casl/abilities/page-ability.factory';
|
||||
import {
|
||||
PageCaslAction,
|
||||
PageCaslSubject,
|
||||
} from '../casl/interfaces/page-ability.type';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -59,9 +44,6 @@ export class PageController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAbility: PageAbilityFactory,
|
||||
private readonly pagePermissionService: PagePermissionService,
|
||||
private readonly sharedPagesRepo: SharedPagesRepo,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -79,20 +61,10 @@ export class PageController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const pageAbility = await this.pageAbility.createForUser(user, page.id);
|
||||
|
||||
if (pageAbility.cannot(PageCaslAction.Read, PageCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
/*const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
page.spaceId,
|
||||
);
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}*/
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
@@ -134,11 +106,7 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(
|
||||
@Body() deletePageDto: DeletePageDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(deletePageDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
@@ -154,27 +122,19 @@ export class PageController {
|
||||
'Only space admins can permanently delete pages',
|
||||
);
|
||||
}
|
||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||
await this.pageService.forceDelete(deletePageDto.pageId);
|
||||
} else {
|
||||
// Soft delete requires page manage permissions
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageService.removePage(
|
||||
deletePageDto.pageId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
await this.pageService.remove(deletePageDto.pageId, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('restore')
|
||||
async restore(
|
||||
@Body() pageIdDto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(pageIdDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
@@ -186,11 +146,13 @@ export class PageController {
|
||||
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,
|
||||
});
|
||||
return restoredPage;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -417,162 +379,4 @@ export class PageController {
|
||||
}
|
||||
return this.pageService.getPageBreadCrumbs(page.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/restrict')
|
||||
async restrictPage(@Body() dto: PageIdDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// TODO: make sure they have access to the page, and can restrict
|
||||
// And the page is not already restricted
|
||||
// They can add and remove page restriction
|
||||
// When a page restriction is removed, we remove the entries in page permissions table.
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.restrictPage(user, page.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/add')
|
||||
async addPageMembers(
|
||||
@Body() dto: AddPageMembersDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.addMembersToPageBatch(
|
||||
dto,
|
||||
user,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/remove')
|
||||
async removePageMember(
|
||||
@Body() dto: RemovePageMemberDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.removePageMember(dto, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/update-role')
|
||||
async updatePageMemberRole(
|
||||
@Body() dto: UpdatePageMemberRoleDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.updatePageMemberRole(dto, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/update')
|
||||
async updatePagePermissions(
|
||||
@Body() dto: UpdatePagePermissionDto,
|
||||
@AuthUser() user: User,
|
||||
): Promise<PagePermissionsResponse> {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.updatePagePermission(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/info')
|
||||
async getPagePermissions(
|
||||
@Body() dto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
): Promise<PagePermissionsResponse> {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pagePermissionService.getPagePermissions(dto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('permissions/list')
|
||||
async getPageMembers(
|
||||
@Body() dto: GetPageMembersDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const pagination: PaginationOptions = {
|
||||
page: dto.page || 1,
|
||||
limit: dto.limit || 20,
|
||||
query: dto.query,
|
||||
};
|
||||
|
||||
return this.pagePermissionService.getPageMembers(
|
||||
dto.pageId,
|
||||
workspace.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('shared')
|
||||
async getUserSharedPages(@AuthUser() user: User) {
|
||||
return this.sharedPagesRepo.getUserSharedPages(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,12 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { PagePermissionService } from './services/page-member.service';
|
||||
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
PagePermissionService,
|
||||
SharedPagesRepo,
|
||||
],
|
||||
exports: [PageService, PageHistoryService, PagePermissionService],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
PagePermissionRepo,
|
||||
PageMemberRole,
|
||||
} from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
import { SharedPagesRepo } from '@docmost/db/repos/page/shared-pages.repo';
|
||||
import { AddPageMembersDto } from '../dto/add-page-members.dto';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Page, PagePermission, User } from '@docmost/db/types/entity.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RemovePageMemberDto } from '../dto/remove-page-member.dto';
|
||||
import { UpdatePageMemberRoleDto } from '../dto/update-page-member-role.dto';
|
||||
import { UpdatePagePermissionDto } from '../dto/update-page-permission.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
export interface IPagePermission {
|
||||
id: string;
|
||||
cascade: boolean;
|
||||
member: {
|
||||
id: string;
|
||||
type: 'user' | 'group' | 'public';
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
workspaceRole?: string;
|
||||
name?: string;
|
||||
memberCount?: number;
|
||||
};
|
||||
membershipRole: {
|
||||
id: string;
|
||||
level: string;
|
||||
source: 'direct' | 'inherited';
|
||||
};
|
||||
grantedBy: {
|
||||
id: string;
|
||||
type: 'page' | 'space';
|
||||
title?: string;
|
||||
name?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PagePermissionsResponse {
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
hasCustomPermissions: boolean;
|
||||
inheritPermissions: boolean;
|
||||
permissions: IPagePermission[];
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PagePermissionService {
|
||||
constructor(
|
||||
private pageMemberRepo: PagePermissionRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private sharedPagesRepo: SharedPagesRepo,
|
||||
private userRepo: UserRepo,
|
||||
private groupRepo: GroupRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async addUserToPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
role: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.pageMemberRepo.insertPageMember(
|
||||
{
|
||||
userId: userId,
|
||||
pageId: pageId,
|
||||
role: role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async addGroupToPage(
|
||||
groupId: string,
|
||||
pageId: string,
|
||||
role: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.pageMemberRepo.insertPageMember(
|
||||
{
|
||||
groupId: groupId,
|
||||
pageId: pageId,
|
||||
role: role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
async getPageMembers(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(pageId);
|
||||
// const page = await this.pageRepo.findById(pageId, { workspaceId });
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const members = await this.pageMemberRepo.getPageMembersPaginated(
|
||||
pageId,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
async restrictPage(authUser: User, pageId: string) {
|
||||
// to add custom permissions to a page,
|
||||
// we have to restrict the page first.
|
||||
// the user is here because they can restrict this page
|
||||
// TODO: make sure page is not in trash
|
||||
// Not sure if normal users can see restricted pages in trash.
|
||||
await this.db
|
||||
.updateTable('pages')
|
||||
.set({
|
||||
isRestricted: true,
|
||||
restrictedById: authUser.id,
|
||||
})
|
||||
.where('id', '=', pageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async addMembersToPageBatch(
|
||||
dto: AddPageMembersDto,
|
||||
authUser: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
//const page = await this.pageRepo.findById(dto.pageId, { workspaceId });
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if (!Object.values(PageMemberRole).includes(dto.role as PageMemberRole)) {
|
||||
throw new BadRequestException(`Invalid role: ${dto.role}`);
|
||||
}
|
||||
|
||||
// Enable custom permissions if adding first member
|
||||
/*if (!page.hasCustomPermissions) {
|
||||
await this.pageRepo.update(dto.pageId, {
|
||||
hasCustomPermissions: true,
|
||||
inheritPermissions: false,
|
||||
});
|
||||
}*/
|
||||
|
||||
// Make sure we have valid workspace users
|
||||
const validUsersQuery = this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('users.id', 'in', dto.userIds)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('pagePermissions')
|
||||
.select('id')
|
||||
.whereRef('pagePermissions.userId', '=', 'users.id')
|
||||
.where('pagePermissions.pageId', '=', dto.pageId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const validGroupsQuery = this.db
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', dto.groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('pagePermissions')
|
||||
.select('id')
|
||||
.whereRef('pagePermissions.groupId', '=', 'groups.id')
|
||||
.where('pagePermissions.pageId', '=', dto.pageId),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
let validUsers = [],
|
||||
validGroups = [];
|
||||
if (dto.userIds && dto.userIds.length > 0) {
|
||||
validUsers = await validUsersQuery.execute();
|
||||
}
|
||||
if (dto.groupIds && dto.groupIds.length > 0) {
|
||||
validGroups = await validGroupsQuery.execute();
|
||||
}
|
||||
|
||||
const usersToAdd = [];
|
||||
for (const user of validUsers) {
|
||||
usersToAdd.push({
|
||||
pageId: dto.pageId,
|
||||
userId: user.id,
|
||||
role: dto.role,
|
||||
addedById: authUser.id,
|
||||
});
|
||||
|
||||
// Track orphaned page access if user doesn't have parent access
|
||||
if (page.parentPageId && dto.role !== PageMemberRole.NONE) {
|
||||
const hasParentAccess = await this.checkParentAccess(
|
||||
user.id,
|
||||
page.parentPageId,
|
||||
);
|
||||
if (!hasParentAccess) {
|
||||
await this.sharedPagesRepo.addSharedPage(user.id, dto.pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groupsToAdd = [];
|
||||
for (const group of validGroups) {
|
||||
groupsToAdd.push({
|
||||
pageId: dto.pageId,
|
||||
groupId: group.id,
|
||||
role: dto.role,
|
||||
addedById: authUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const membersToAdd = [...usersToAdd, ...groupsToAdd];
|
||||
if (membersToAdd.length > 0) {
|
||||
await this.db
|
||||
.insertInto('pagePermissions')
|
||||
.values(membersToAdd)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Apply to child pages if requested
|
||||
if (dto.cascade) {
|
||||
await this.cascadeToChildren(dto.pageId, membersToAdd);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof BadRequestException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'Failed to add members to page. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removePageMember(
|
||||
dto: RemovePageMemberDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const member = await this.db
|
||||
.selectFrom('pagePermissions')
|
||||
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
|
||||
.select(['pagePermissions.id', 'pagePermissions.userId'])
|
||||
.where('pagePermissions.id', '=', dto.memberId)
|
||||
.where('pagePermissions.pageId', '=', dto.pageId)
|
||||
.where('pages.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException('Page member not found');
|
||||
}
|
||||
|
||||
// Check if this is the last admin
|
||||
const adminCount = await this.pageMemberRepo.roleCountByPageId(
|
||||
PageMemberRole.ADMIN,
|
||||
dto.pageId,
|
||||
);
|
||||
|
||||
if (adminCount === 1) {
|
||||
const memberToRemove = await this.pageMemberRepo.getPageMemberByTypeId(
|
||||
dto.pageId,
|
||||
{ userId: member.userId },
|
||||
);
|
||||
if (memberToRemove?.role === PageMemberRole.ADMIN) {
|
||||
throw new BadRequestException('Cannot remove the last admin from page');
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageMemberRepo.removePageMemberById(dto.memberId, dto.pageId);
|
||||
|
||||
// Remove from shared pages if it was tracked
|
||||
if (member.userId) {
|
||||
await this.sharedPagesRepo.removeSharedPage(member.userId, dto.pageId);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePageMemberRole(
|
||||
dto: UpdatePageMemberRoleDto,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const member = await this.db
|
||||
.selectFrom('pagePermissions')
|
||||
.innerJoin('pages', 'pages.id', 'pagePermissions.pageId')
|
||||
.select(['pagePermissions.id', 'pagePermissions.role'])
|
||||
.where('pagePermissions.id', '=', dto.memberId)
|
||||
.where('pagePermissions.pageId', '=', dto.pageId)
|
||||
.where('pages.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException('Page member not found');
|
||||
}
|
||||
|
||||
if (
|
||||
member.role === PageMemberRole.ADMIN &&
|
||||
dto.role !== PageMemberRole.ADMIN
|
||||
) {
|
||||
const adminCount = await this.pageMemberRepo.roleCountByPageId(
|
||||
PageMemberRole.ADMIN,
|
||||
dto.pageId,
|
||||
);
|
||||
if (adminCount === 1) {
|
||||
throw new BadRequestException('Cannot change role of the last admin');
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageMemberRepo.updatePageMember(
|
||||
{ role: dto.role },
|
||||
dto.memberId,
|
||||
dto.pageId,
|
||||
);
|
||||
}
|
||||
|
||||
async updatePagePermission(
|
||||
dto: UpdatePagePermissionDto,
|
||||
): Promise<PagePermissionsResponse> {
|
||||
const { pageId, userId, groupId, role, cascade } = dto;
|
||||
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!userId && !groupId) {
|
||||
throw new BadRequestException(
|
||||
'Either userId or groupId must be provided',
|
||||
);
|
||||
}
|
||||
|
||||
if (userId && groupId) {
|
||||
throw new BadRequestException('Cannot provide both userId and groupId');
|
||||
}
|
||||
|
||||
if (!Object.values(PageMemberRole).includes(role as PageMemberRole)) {
|
||||
throw new BadRequestException(`Invalid role: ${role}`);
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
// Update the role
|
||||
if (userId) {
|
||||
await this.pageMemberRepo.upsertPageMember(
|
||||
{
|
||||
pageId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
} else if (groupId) {
|
||||
await this.pageMemberRepo.upsertPageMember(
|
||||
{
|
||||
pageId,
|
||||
groupId,
|
||||
role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
// Mark page as having custom permissions
|
||||
/* await this.pageRepo.update(
|
||||
pageId,
|
||||
{
|
||||
hasCustomPermissions: true,
|
||||
inheritPermissions: false,
|
||||
},
|
||||
trx,
|
||||
);*/
|
||||
|
||||
// Cascade to children if requested
|
||||
if (cascade) {
|
||||
const descendants = await this.pageRepo.getAllDescendants(
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
for (const childId of descendants) {
|
||||
if (userId) {
|
||||
await this.pageMemberRepo.upsertPageMember(
|
||||
{
|
||||
pageId: childId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
} else if (groupId) {
|
||||
await this.pageMemberRepo.upsertPageMember(
|
||||
{
|
||||
pageId: childId,
|
||||
groupId,
|
||||
role,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Return comprehensive permission data
|
||||
return this.getPagePermissions(pageId);
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException(
|
||||
'Failed to update page permissions. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getPagePermissions(pageId: string): Promise<PagePermissionsResponse> {
|
||||
const page = await this.pageRepo.findById(pageId, { includeSpace: true });
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const permissions: IPagePermission[] = [];
|
||||
|
||||
// 1. Get direct page members
|
||||
const directMembers = await this.pageMemberRepo.getPageMembers(pageId);
|
||||
|
||||
// Batch fetch all users and groups
|
||||
const userIds = directMembers.filter((m) => m.userId).map((m) => m.userId);
|
||||
const groupIds = directMembers
|
||||
.filter((m) => m.groupId)
|
||||
.map((m) => m.groupId);
|
||||
|
||||
const [users, groups] = await Promise.all([
|
||||
userIds.length > 0
|
||||
? this.db
|
||||
.selectFrom('users')
|
||||
.selectAll()
|
||||
.where('id', 'in', userIds)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
groupIds.length > 0
|
||||
? this.db
|
||||
.selectFrom('groups')
|
||||
.selectAll()
|
||||
.where('id', 'in', groupIds)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u] as const));
|
||||
const groupMap = new Map(groups.map((g) => [g.id, g] as const));
|
||||
|
||||
// Build permissions with batch-fetched data
|
||||
for (const member of directMembers) {
|
||||
let memberData: any = null;
|
||||
|
||||
if (member.userId) {
|
||||
const user = userMap.get(member.userId);
|
||||
if (user) {
|
||||
memberData = {
|
||||
id: user.id,
|
||||
type: 'user' as const,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
workspaceRole: user.role,
|
||||
};
|
||||
}
|
||||
} else if (member.groupId) {
|
||||
const group = groupMap.get(member.groupId);
|
||||
if (group) {
|
||||
memberData = {
|
||||
id: group.id,
|
||||
type: 'group' as const,
|
||||
name: group.name,
|
||||
memberCount: await this.db
|
||||
.selectFrom('groupUsers')
|
||||
.select((eb) => eb.fn.count('userId').as('count'))
|
||||
.where('groupId', '=', group.id)
|
||||
.executeTakeFirst()
|
||||
.then((result) => Number(result?.count || 0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (memberData) {
|
||||
permissions.push({
|
||||
id: member.id,
|
||||
cascade: true, // Page permissions cascade by default
|
||||
member: memberData,
|
||||
membershipRole: {
|
||||
id: member.id,
|
||||
level: member.role,
|
||||
source: 'direct',
|
||||
},
|
||||
grantedBy: {
|
||||
id: pageId,
|
||||
type: 'page',
|
||||
title: page.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get inherited space members (if page inherits)
|
||||
if (page) {
|
||||
//if (page.inheritPermissions || !page.hasCustomPermissions) {
|
||||
const spaceMembers = await this.spaceMemberRepo.getSpaceMembersPaginated(
|
||||
page.spaceId,
|
||||
{ page: 1, limit: 100 },
|
||||
);
|
||||
|
||||
for (const spaceMember of spaceMembers.items as any[]) {
|
||||
// Skip if user has direct page permission
|
||||
const hasDirect = directMembers.some(
|
||||
(dm) =>
|
||||
(dm.userId === spaceMember.id && spaceMember.type === 'user') ||
|
||||
(dm.groupId === spaceMember.id && spaceMember.type === 'group'),
|
||||
);
|
||||
if (!hasDirect) {
|
||||
permissions.push({
|
||||
id: `space-${spaceMember.id}`,
|
||||
cascade: false, // Space permissions don't cascade to page children
|
||||
member: {
|
||||
id: spaceMember.id,
|
||||
type: spaceMember.type as 'user' | 'group',
|
||||
email: spaceMember.email,
|
||||
displayName: spaceMember.name,
|
||||
avatarUrl: spaceMember.avatarUrl,
|
||||
name: spaceMember.name,
|
||||
memberCount: Number(spaceMember.memberCount || 0),
|
||||
},
|
||||
membershipRole: {
|
||||
id: `space-role-${spaceMember.id}`,
|
||||
level: spaceMember.role,
|
||||
source: 'inherited',
|
||||
},
|
||||
grantedBy: {
|
||||
id: page.spaceId,
|
||||
type: 'space',
|
||||
name: (page as any).space?.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
page: {
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
hasCustomPermissions: true,
|
||||
inheritPermissions: false,
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async checkParentAccess(
|
||||
userId: string,
|
||||
parentPageId: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!parentPageId) return true; // Root pages always accessible
|
||||
|
||||
const parentAccess = await this.pageMemberRepo.resolveUserPageAccess(
|
||||
userId,
|
||||
parentPageId,
|
||||
);
|
||||
return parentAccess !== null && parentAccess !== PageMemberRole.NONE;
|
||||
}
|
||||
|
||||
private async cascadeToChildren(
|
||||
pageId: string,
|
||||
membersToAdd: any[],
|
||||
): Promise<void> {
|
||||
const descendants = await this.pageRepo.getAllDescendants(pageId);
|
||||
if (descendants.length === 0) return;
|
||||
|
||||
// Separate user and group members for proper conflict handling
|
||||
const userMembers = membersToAdd.filter((m) => m.userId);
|
||||
const groupMembers = membersToAdd.filter((m) => m.groupId);
|
||||
|
||||
for (const childId of descendants) {
|
||||
// Handle user members with proper conflict resolution
|
||||
if (userMembers.length > 0) {
|
||||
const childUserMembers = userMembers.map((m) => ({
|
||||
...m,
|
||||
pageId: childId,
|
||||
}));
|
||||
|
||||
await this.db
|
||||
.insertInto('pagePermissions')
|
||||
.values(childUserMembers)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['pageId', 'userId']).doUpdateSet({
|
||||
role: (eb) => eb.ref('excluded.role'),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Handle group members separately
|
||||
if (groupMembers.length > 0) {
|
||||
const childGroupMembers = groupMembers.map((m) => ({
|
||||
...m,
|
||||
pageId: childId,
|
||||
}));
|
||||
|
||||
await this.db
|
||||
.insertInto('pagePermissions')
|
||||
.values(childGroupMembers)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['pageId', 'groupId']).doUpdateSet({
|
||||
role: (eb) => eb.ref('excluded.role'),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ export class PageService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
@@ -256,11 +255,6 @@ export class PageService {
|
||||
pageIds,
|
||||
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,
|
||||
creatorId: authUser.id,
|
||||
lastUpdatedById: authUser.id,
|
||||
parentPageId: page.id === rootPage.id
|
||||
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
|
||||
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
|
||||
parentPageId: page.parentPageId
|
||||
? pageMap.get(page.parentPageId)?.newPageId
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -399,7 +393,6 @@ export class PageService {
|
||||
const insertedPageIds = insertablePages.map((page) => page.id);
|
||||
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
||||
pageIds: insertedPageIds,
|
||||
workspaceId: authUser.workspaceId,
|
||||
});
|
||||
|
||||
//TODO: best to handle this in a queue
|
||||
@@ -587,7 +580,7 @@ export class PageService {
|
||||
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
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
@@ -630,16 +623,11 @@ export class PageService {
|
||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||
this.eventEmitter.emit(EventName.PAGE_DELETED, {
|
||||
pageIds: pageIds,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removePage(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
async remove(pageId: string, userId: string): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class SearchService {
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('rank', 'desc')
|
||||
.limit(searchParams.limit | 25)
|
||||
.limit(searchParams.limit | 20)
|
||||
.offset(searchParams.offset || 0);
|
||||
|
||||
if (!searchParams.shareId) {
|
||||
|
||||
@@ -69,8 +69,8 @@ export class ShareService {
|
||||
return await this.shareRepo.insertShare({
|
||||
key: nanoIdGen().toLowerCase(),
|
||||
pageId: page.id,
|
||||
includeSubPages: createShareDto.includeSubPages ?? false,
|
||||
searchIndexing: createShareDto.searchIndexing ?? false,
|
||||
includeSubPages: createShareDto.includeSubPages || true,
|
||||
searchIndexing: createShareDto.searchIndexing || true,
|
||||
creatorId: authUserId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
|
||||
export class CreateSpaceDto {
|
||||
@MinLength(2)
|
||||
|
||||
@@ -70,9 +70,7 @@ export class UserService {
|
||||
);
|
||||
|
||||
if (!isPasswordMatch) {
|
||||
throw new BadRequestException(
|
||||
'You must provide the correct password to change your email',
|
||||
);
|
||||
throw new BadRequestException('You must provide the correct password to change your email');
|
||||
}
|
||||
|
||||
if (await this.userRepo.findByEmail(updateUserDto.email, workspace.id)) {
|
||||
|
||||
@@ -22,12 +22,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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 { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -51,7 +50,6 @@ export class WorkspaceService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -314,51 +312,6 @@ export class WorkspaceService {
|
||||
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);
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission-repo.service';
|
||||
|
||||
// https://github.com/brianc/node-postgres/issues/811
|
||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
@@ -79,7 +78,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PageListener,
|
||||
PagePermissionRepo,
|
||||
],
|
||||
exports: [
|
||||
WorkspaceRepo,
|
||||
@@ -95,7 +93,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PagePermissionRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule
|
||||
|
||||
@@ -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 { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
export class PageEvent {
|
||||
pageIds: string[];
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -16,65 +14,36 @@ export class PageListener {
|
||||
private readonly logger = new Logger(PageListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
) {}
|
||||
|
||||
@OnEvent(EventName.PAGE_CREATED)
|
||||
async handlePageCreated(event: PageEvent) {
|
||||
const { pageIds, workspaceId } = event;
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
|
||||
pageIds,
|
||||
});
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
||||
const { pageIds } = event;
|
||||
await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds });
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_UPDATED)
|
||||
async handlePageUpdated(event: PageEvent) {
|
||||
const { pageIds } = event;
|
||||
|
||||
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_DELETED)
|
||||
async handlePageDeleted(event: PageEvent) {
|
||||
const { pageIds, workspaceId } = event;
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
||||
const { pageIds } = event;
|
||||
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
||||
async handlePageSoftDeleted(event: PageEvent) {
|
||||
const { pageIds, workspaceId } = event;
|
||||
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
||||
pageIds,
|
||||
workspaceId,
|
||||
});
|
||||
const { pageIds } = event;
|
||||
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_RESTORED)
|
||||
async handlePageRestored(event: PageEvent) {
|
||||
const { pageIds, workspaceId } = event;
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
||||
}
|
||||
|
||||
isTypesense(): boolean {
|
||||
return this.environmentService.getSearchDriver() === 'typesense';
|
||||
const { pageIds } = event;
|
||||
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { type Kysely, sql } from 'kysely';
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
|
||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_permissions')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('group_id', 'uuid', (col) =>
|
||||
col.references('groups.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('role', 'varchar', (col) => col.notNull())
|
||||
.addColumn('cascade', 'boolean', (col) => col.defaultTo(true).notNull()) // children can inherit
|
||||
.addColumn('added_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.addUniqueConstraint('unique_page_user', ['page_id', 'user_id'])
|
||||
.addUniqueConstraint('unique_page_group', ['page_id', 'group_id'])
|
||||
.addCheckConstraint(
|
||||
'allow_either_user_id_or_group_id_check',
|
||||
sql`(user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.addColumn('is_restricted', 'boolean', (col) =>
|
||||
col.defaultTo(false).notNull(),
|
||||
)
|
||||
.addColumn('restricted_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Add indexes for performance
|
||||
await db.schema
|
||||
.createIndex('idx_page_permissions_page_id')
|
||||
.on('page_permissions')
|
||||
.column('page_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_permissions_user_id')
|
||||
.on('page_permissions')
|
||||
.column('user_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_permissions_group_id')
|
||||
.on('page_permissions')
|
||||
.column('group_id')
|
||||
.execute();
|
||||
|
||||
// Create user_shared_pages table for tracking orphaned page access
|
||||
await db.schema
|
||||
.createTable('user_shared_pages')
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.notNull().references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('shared_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addPrimaryKeyConstraint('user_shared_pages_pkey', ['user_id', 'page_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_user_shared_pages_user_id')
|
||||
.on('user_shared_pages')
|
||||
.column('user_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_user_shared_pages_shared_at')
|
||||
.on('user_shared_pages')
|
||||
.column('shared_at')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('pages').dropColumn('is_restricted').execute();
|
||||
await db.schema.alterTable('pages').dropColumn('restricted_by_id').execute();
|
||||
|
||||
await db.schema.dropTable('user_shared_pages').execute();
|
||||
|
||||
await db.schema.dropTable('page_permissions').execute();
|
||||
}
|
||||
@@ -23,9 +23,9 @@ export class PaginationOptions {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query?: string;
|
||||
query: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
adminView?: boolean;
|
||||
adminView: boolean;
|
||||
}
|
||||
|
||||
@@ -105,10 +105,7 @@ export class CommentRepo {
|
||||
return Number(result?.count) > 0;
|
||||
}
|
||||
|
||||
async hasChildrenFromOtherUsers(
|
||||
commentId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.selectFrom('comments')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
|
||||
@@ -57,11 +57,7 @@ export class GroupUserRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb(
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,11 +114,7 @@ export class GroupRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb(
|
||||
sql`f_unaccent(name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
).or(
|
||||
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or(
|
||||
sql`f_unaccent(description)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user