Compare commits

..

31 Commits

Author SHA1 Message Date
Philipinho 5eb3416b5c namespace 2026-01-17 14:51:56 +00:00
Philipinho c1cfe158cd smart scene sync 2026-01-17 14:39:27 +00:00
Philipinho ab81903299 ignore ts error 2026-01-17 13:44:04 +00:00
Philipinho 14698ebb05 fix room exit 2026-01-17 13:42:58 +00:00
Philipinho efa52ea4c8 Live cursor 2026-01-17 13:36:35 +00:00
Philipinho a4750bff56 WIP - POC 2026-01-17 13:24:54 +00:00
Philipinho 5c9eed53c0 websocket - WIP 2026-01-17 03:15:58 +00:00
Philipinho bcb004af21 update lockfile 2026-01-16 13:22:41 +00:00
Philipinho ac675e7d74 update dockerfile 2026-01-16 13:21:42 +00:00
Philipinho bf89eff5e7 sync 2026-01-16 13:20:31 +00:00
Philip Okugbe 183787fa0c fix: update dependencies (#1843) 2026-01-14 16:36:47 +00:00
Philipinho 15aa04a5f7 sync 2026-01-14 11:49:39 +00:00
Philipinho 79343a5d52 fix: prevent text overflow in group and space list tables 2026-01-13 16:25:42 +00:00
Philipinho 61e252918e fix length 2026-01-13 16:13:52 +00:00
Philipinho e98fa7f69a sync
* fix form length
2026-01-13 16:13:04 +00:00
Philip Okugbe 6d148a35eb New Crowdin updates (#1830)
* New translations translation.json (Japanese)

* New translations translation.json (Japanese)
2026-01-13 16:01:08 +00:00
Philip Okugbe 0bbc1c35de fix: public sharing performance improvements (#1841) 2026-01-13 16:00:22 +00:00
Philip Okugbe 47097969a0 fix: use subquery (#1833)
- enhance file tasks list endpoint
2026-01-13 15:58:26 +00:00
Philip Okugbe 13f529e064 fix anchor scroll in same page (#1834) 2026-01-13 15:35:53 +00:00
Philip Okugbe 8fc8422fbc fix: increase max length for groups and spaces (#1840) 2026-01-13 15:31:03 +00:00
Philipinho 732951a322 v0.24.1 2025-12-14 13:24:09 +00:00
Philipinho 2544775266 fix: switch to node slim image 2025-12-14 13:16:40 +00:00
Philipinho d59539f197 fix ai streaming 2025-12-13 14:15:41 +00:00
Philipinho b061df7f7d Use new fastify router options 2025-12-13 14:15:06 +00:00
Philipinho 0fe1459864 fix: override jsonwebtoken version 2025-12-12 17:25:27 +00:00
Philipinho 6af7956889 v0.24.0 2025-12-12 17:15:59 +00:00
Philip Okugbe 3dbb957bd7 New Crowdin updates (#1541)
* New translations translation.json (Dutch)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Russian)

* New translations translation.json (German)

* New translations translation.json (German)

* New translations translation.json (German)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Russian)

* New translations translation.json (Spanish)

* New translations translation.json (Korean)

* New translations translation.json (Korean)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-12-12 17:15:19 +00:00
Philipinho f39a4cf2d5 fix space modal spacing 2025-12-12 14:08:30 +00:00
Philipinho 724e01bd55 fix default page share state (API) 2025-12-11 20:43:26 +00:00
Philip Okugbe 6e350f6746 fix nodeview dragging (#1775) 2025-12-11 19:32:18 +00:00
Philip Okugbe cb9f27da9a fix mermaid security (#1774) 2025-12-11 16:44:52 +00:00
67 changed files with 3587 additions and 1108 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
node_modules
.git
.gitignore
dist
data
/data
.env*
.nx
+7 -5
View File
@@ -1,19 +1,22 @@
FROM node:22-alpine AS base
FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@10.4.0
FROM base AS builder
WORKDIR /app
COPY . .
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile
RUN pnpm build
FROM base AS installer
RUN apk add --no-cache curl bash
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -29,12 +32,11 @@ COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-e
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/pnpm*.yaml /app/
COPY --from=builder /app/.npmrc /app/.npmrc
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app
USER node
+22 -23
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.23.2",
"version": "0.24.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -10,51 +10,50 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
},
"dependencies": {
"@casl/ability": "^6.7.2",
"@casl/react": "^4.0.0",
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
"@mantine/notifications": "^8.1.3",
"@mantine/spotlight": "^8.1.3",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"@excalidraw/excalidraw": "0.18.0-c158187",
"@mantine/core": "^8.3.12",
"@mantine/dates": "^8.3.12",
"@mantine/form": "^8.3.12",
"@mantine/hooks": "^8.3.12",
"@mantine/modals": "^8.3.12",
"@mantine/notifications": "^8.3.12",
"@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.27.1",
"alfaaz": "^1.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.12.5",
"i18next": "^23.16.8",
"i18next-http-backend": "^2.7.3",
"jotai": "^2.16.2",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "0.16.22",
"katex": "0.16.27",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.11.0",
"mermaid": "^11.12.2",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",
"react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.1",
"react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"react-router-dom": "^7.12.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
@@ -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": "Einzelheiten",
"Details": "Details",
"e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler",
@@ -525,5 +525,47 @@
"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"
"{{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"
}
@@ -527,5 +527,47 @@
"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}}"
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}",
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color"
}
@@ -527,5 +527,47 @@
"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}}"
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}",
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
}
@@ -527,5 +527,47 @@
"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}}"
"{{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"
}
+181 -139
View File
@@ -13,21 +13,21 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "このユーザをグループから削除してもよろしいですか? ユーザはこのグループがアクセス権を持つリソースにアクセスできなくなります。",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "このユーザをスペースから削除してもよろしいですか? ユーザはこのスペースへのアクセス権をすべて失います。",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "このバージョンを復元してもよろしいですか? バージョン管理されていない変更は失われます。",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになることができます",
"Can create and edit pages in space.": "スペース内のページを作成および編集できます",
"Can become members of groups and spaces in workspace": "ワークスペース内のグループやスペースのメンバーになます",
"Can create and edit pages in space.": "スペース内のページを作成編集できます",
"Can edit": "編集可能",
"Can manage workspace": "ワークスペースを管理できます",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can manage workspace but cannot delete it": "ワークスペースを管理できますが削除はできません",
"Can view": "閲覧可能",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Can view pages in space but not edit.": "スペース内のページを閲覧できますが編集はできません",
"Cancel": "キャンセル",
"Change email": "メールアドレスの変更",
"Change password": "パスワードの変更",
"Change photo": "画像の変更",
"Choose a role": "ロールを選んでください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みのインターフェース言語を選択してください",
"Choose your preferred page width.": "左右の余白を縮小する場合はオンにしてください",
"Choose your preferred color scheme.": "お好みのカラースキームを選択してください",
"Choose your preferred interface language.": "お好みの言語を選択してください",
"Choose your preferred page width.": "お好みのページ幅を選択してください",
"Confirm": "確認",
"Copy link": "リンクをコピー",
"Create": "新規作成",
@@ -40,24 +40,24 @@
"Date": "日付",
"Delete": "削除",
"Delete group": "グループを削除",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?この操作により、子ページおよびページ履歴削除されます。この操作は元に戻せません。",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "このページを削除してもよろしいですか?子ページページ履歴削除されます。この操作は取り消せません。",
"Description": "説明",
"Details": "詳細",
"e.g ACME": "例: 山田太郎",
"e.g ACME Inc": "例: 株式会社サンプル",
"e.g Developers": "例: エンジニア",
"e.g Group for developers": "例: エンジニアグループ",
"e.g Group for developers": "例: 開発チーム",
"e.g product": "例: product",
"e.g Product Team": "例: 製品チーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: 製品チームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"e.g Product Team": "例: プロダクトチーム",
"e.g Sales": "例: 営業",
"e.g Space for product team": "例: プロダクトチームスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム用スペース",
"Edit": "編集",
"Read": "読む",
"Read": "閲覧",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
"Enter valid email addresses separated by comma or space max_50": "有効なメールアドレスをカンマまたはスペース区切って入力してください(最大 50",
"Enter valid email addresses separated by comma or space max_50": "メールアドレスをカンマまたはスペース区切りで入力(最大50",
"enter valid emails addresses": "有効なメールアドレスを入力してください",
"Enter your current password": "現在のパスワードを入力してください",
"enter your full name": "氏名を入力してください",
@@ -81,18 +81,18 @@
"Group description": "グループ説明",
"Group name": "グループ名",
"Groups": "グループ",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Has full access to space settings and pages.": "スペース設定とページにフルアクセスできます",
"Home": "ホーム",
"Import pages": "ページをインポート",
"Import pages & space settings": "ページとスペース設定をインポート",
"Importing pages": "ページをインポートしています",
"invalid invitation link": "招待リンクが間違っています",
"invalid invitation link": "無効な招待リンクす",
"Invitation signup": "招待登録",
"Invite by email": "メールアドレスで招待する",
"Invite members": "メンバーを招待する",
"Invite new members": "新しいメンバーを招待する",
"Invited members who are yet to accept their invitation will appear here.": "招待をまだ承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセス権が付与されます",
"Invited members who are yet to accept their invitation will appear here.": "招待を承諾していないメンバーここに表示されます",
"Invited members will be granted access to spaces the groups can access": "招待されたメンバーはグループがアクセスできるスペースにアクセスできます",
"Join the workspace": "ワークスペースに参加",
"Language": "言語",
"Light": "ライト",
@@ -113,20 +113,20 @@
"New page": "新規ページ",
"New password": "新しいパスワード",
"No group found": "グループが見つかりません",
"No page history saved yet.": "まだページ履歴が保存されていません",
"No page history saved yet.": "ページ履歴がありません",
"No pages yet": "ページがありません",
"No results found...": "結果が見つかりませんでした...",
"No user found": "ユーザがいません",
"No results found...": "結果が見つかりません",
"No user found": "ユーザーが見つかりません",
"Overview": "概要",
"Owner": "所有者",
"page": "ページ",
"Page deleted successfully": "ページが正常に削除されました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポートが進行中です。このタブを閉じないでください",
"Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴",
"Page import is in progress. Please do not close this tab.": "ページインポート中です。このタブを閉じないでください",
"Pages": "ページ",
"pages": "ページ",
"Password": "パスワード",
"Password changed successfully": "パスワードが正常に変更されました",
"Password changed successfully": "パスワードを変更しました",
"Pending": "保留中",
"Please confirm your action": "アクションを確認してください",
"Preferences": "設定",
@@ -143,95 +143,95 @@
"Search for groups": "グループを検索",
"Search for users": "ユーザーを検索",
"Search for users and groups": "ユーザーとグループを検索",
"Search...": "検索する語句を入力",
"Search...": "検索",
"Select language": "言語を選択",
"Select role": "ロールを選択",
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select role to assign to all invited members": "招待するメンバーに割り当てるロールを選択",
"Select theme": "テーマを選択",
"Send invitation": "招待を送る",
"Invitation sent": "招待送信されました",
"Invitation sent": "招待送信ました",
"Settings": "設定",
"Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン",
"Sign Up": "アカウント登録",
"Slug": "Slug (URL用文字列)",
"Sign Up": "新規登録",
"Slug": "スラッグ(URL識別子)",
"Space": "スペース",
"Space description": "スペース説明",
"Space menu": "スペースメニュー",
"Space name": "スペース名",
"Space settings": "スペース設定",
"Space slug": "スペースのSlug (URL用文字列)",
"Space slug": "スペースのスラッグ(URL識別子)",
"Spaces": "スペース",
"Spaces you belong to": "所属しているスペース",
"No space found": "スペースが見つかりません",
"Search for spaces": "スペースを検索",
"Start typing to search...": "検索を開始するには入力してください...",
"Start typing to search...": "入力して検索",
"Status": "ステータス",
"Successfully imported": "インポートに成功しました",
"Successfully restored": "正常に復元されました",
"Successfully imported": "インポートしました",
"Successfully restored": "復元しました",
"System settings": "システム設定",
"Theme": "テーマ",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力する必要があります。",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできません。もう一度お試しください",
"To change your email, you have to enter your password and new email.": "メールアドレスを変更するには、パスワードと新しいメールアドレスを入力してください",
"Toggle full page width": "ページ幅を切り替え",
"Unable to import pages. Please try again.": "ページをインポートできませんでした。もう一度お試しください",
"untitled": "無題",
"Untitled": "無題",
"Updated successfully": "正常に更新されました",
"Updated successfully": "更新しました",
"User": "ユーザー",
"Workspace": "ワークスペース",
"Workspace Name": "ワークスペース名",
"Workspace settings": "ワークスペース設定",
"You can change your password here.": "パスワードを変更できます",
"You can change your password here.": "パスワードを変更できます",
"Your Email": "メールアドレス",
"Your import is complete.": "インポートが完了しました",
"Your import is complete.": "インポートが完了しました",
"Your name": "名前",
"Your Name": "名前",
"Your password": "パスワード",
"Your password must be a minimum of 8 characters.": "パスワードは最低 8 文字必要です。",
"Your password must be a minimum of 8 characters.": "パスワードは8文字以上にしてください",
"Sidebar toggle": "サイドバー切り替え",
"Comments": "コメント",
"404 page not found": "404 ページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Sorry, we can't find the page you are looking for.": "お探しのページが見つかりません",
"Take me back to homepage": "ホームに戻る",
"Forgot password": "パスワードを忘れた",
"Forgot your password?": "パスワードを忘れましたか?",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセットリンクがあなたのメールアドレスに送信されました。受信を確認してください",
"Send reset link": "リセットリンクを送",
"A password reset link has been sent to your email. Please check your inbox.": "パスワードリセット用のリンクをメールに送信ました。受信トレイを確認してください",
"Send reset link": "リセットリンクを送",
"Password reset": "パスワードリセット",
"Your new password": "新しいパスワード",
"Set password": "パスワードを設定",
"Write a comment": "コメントを書く",
"Reply...": "返信...",
"Error loading comments.": "コメントの読み込み中にエラーが発生しました",
"No comments yet.": "コメントがありません",
"Error loading comments.": "コメントの読み込みに失敗しました",
"No comments yet.": "コメントがありません",
"Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Comment created successfully": "コメント作成されました",
"Error creating comment": "コメントの作成中にエラーが発生しました",
"Comment updated successfully": "コメント更新されました",
"Comment created successfully": "コメント作成ました",
"Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメント更新ました",
"Failed to update comment": "コメントの更新に失敗しました",
"Comment deleted successfully": "コメント削除されました",
"Comment deleted successfully": "コメント削除ました",
"Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメント解決されました",
"Comment re-opened successfully": "コメント再開されました",
"Comment unresolved successfully": "コメントが再解決されました",
"Comment resolved successfully": "コメント解決ました",
"Comment re-opened successfully": "コメント再開ました",
"Comment unresolved successfully": "コメントを未解決に戻しました",
"Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決",
"Unresolve comment": "コメントを解決に戻す",
"Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決しますか?",
"Unresolve Comment Thread": "コメントスレッドを解決に戻す",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか完了としてマークされます",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを解決に戻しますか?",
"Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決されたコメントはありません",
"No active comments.": "アクティブなコメントはありません",
"No resolved comments.": "解決済みのコメントはありません",
"Revoke invitation": "招待を取り消す",
"Revoke": "取り消す",
"Don't": "取り消さない",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですか? ユーザはワークスペースに参加できなくなります",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "この招待を取り消してもよろしいですかユーザはワークスペースに参加できなくなります",
"Resend invitation": "招待を再度送る",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもこのワークスペースに参加できます",
"Anyone with this link can join this workspace.": "このリンクをっている人は誰でもワークスペースに参加できます",
"Invite link": "招待リンク",
"Copy": "コピー",
"Copy to space": "スペースにコピー",
@@ -239,13 +239,13 @@
"Duplicate": "複製",
"Select a user": "ユーザを選択",
"Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします",
"Delete space": "スペースを削除",
"Are you sure you want to delete this space?": "このスペースを削除してもよろしいですか?",
"Delete this space with all its pages and data.": "このスペースおよびスペース内のすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "このスペース内のすべてのページ、コメント、添付ファイル、および権限完全に削除されます",
"Delete this space with all its pages and data.": "このスペースすべてのページデータを削除します",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "スペース内のすべてのページ、コメント、添付ファイル、権限完全に削除されます",
"Confirm space name": "スペース名を確認する",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "アクションを確認するためスペース名 <b>{{spaceName}}</b> を入力してください",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "確認のためスペース名 <b>{{spaceName}}</b> を入力してください",
"Format": "フォーマット",
"Include subpages": "サブページを含める",
"Include attachments": "添付ファイルを含める",
@@ -273,12 +273,12 @@
"Success": "成功",
"Warning": "警告",
"Danger": "危険",
"Mermaid diagram error:": "Mermaid コードエラー",
"Invalid Mermaid diagram": "無効な Mermaid コードです",
"Double-click to edit Draw.io diagram": "ダブルクリックしてDraw.io図を編集",
"Mermaid diagram error:": "Mermaid ダイアグラムエラー:",
"Invalid Mermaid diagram": "無効な Mermaid ダイアグラムです",
"Double-click to edit Draw.io diagram": "ダブルクリックして Draw.io 図を編集",
"Exit": "終了",
"Save & Exit": "保存して終了",
"Double-click to edit Excalidraw diagram": "ダブルクリックしてExcalidraw図を編集",
"Double-click to edit Excalidraw diagram": "ダブルクリックして Excalidraw 図を編集",
"Paste link": "リンクを貼り付け",
"Edit link": "リンクを編集",
"Remove link": "リンクを削除",
@@ -315,22 +315,22 @@
"Bullet List": "箇条書きリスト",
"Numbered List": "番号付きリスト",
"Blockquote": "引用",
"Just start typing with plain text.": "すぐに文章を書き始められます",
"Track tasks with a to-do list.": "Todoリストでタスクを追跡します",
"Big section heading.": "大きいフォントのセクション見出しです。",
"Medium section heading.": "中くらいのフォントのセクション見出しです。",
"Small section heading.": "小さいフォントのセクション見出しです。",
"Create a simple bullet list.": "シンプルな箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用を作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "水平線を挿入します",
"Upload any image from your device.": "画像をアップロードします",
"Upload any video from your device.": "動画をアップロードします",
"Upload any file from your device.": "ファイルをアップロードします",
"Just start typing with plain text.": "プレーンテキストを入力します",
"Track tasks with a to-do list.": "Todo リストでタスクを管理します",
"Big section heading.": "大見出し",
"Medium section heading.": "中見出し",
"Small section heading.": "小見出し",
"Create a simple bullet list.": "箇条書きリストを作成します",
"Create a list with numbering.": "番号付きリストを作成します",
"Create block quote.": "引用ブロックを作成します",
"Insert code snippet.": "コードスニペットを入します",
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Table": "テーブル",
"Insert a table.": "を挿入します",
"Insert collapsible block.": "折りたたみ可能なブロックを挿入します",
"Insert a table.": "テーブルを挿入します",
"Insert collapsible block.": "折りたたみブロックを挿入します",
"Video": "動画",
"Divider": "区切り線",
"Quote": "引用",
@@ -338,16 +338,16 @@
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
"Insert callout notice.": "コールアウトブロックを挿入します",
"Insert callout notice.": "コールアウトを挿入します",
"Math inline": "インライン数式",
"Insert inline math equation.": "インライン数式を挿入します",
"Insert inline math equation.": "インライン数式を挿入します",
"Math block": "数式ブロック",
"Insert math equation": "数式を挿入します",
"Mermaid diagram": "Mermaidコード",
"Insert mermaid diagram": "Mermaidコードを記述して図を挿入します",
"Insert and design Drawio diagrams": "Drawio図を挿入してデザインします",
"Insert current date": "今日の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw図を埋め込みます",
"Mermaid diagram": "Mermaid ダイアグラム",
"Insert mermaid diagram": "Mermaid ダイアグラムを挿入します",
"Insert and design Drawio diagrams": "Draw.io 図を挿入・編集します",
"Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数",
"Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
@@ -357,29 +357,29 @@
"Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペース削除されました",
"Space deleted successfully": "スペース削除ました",
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバー削除されました",
"Member removed successfully": "メンバー削除ました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "作成しました:{{time}}",
"Created at: {{time}}": "作成日: {{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Word count: {{wordCount}}": "単語数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"{{latestVersion}} is available": "{{latestVersion}}利用可能です",
"Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ます",
"Choose your preferred page edit mode. Avoid accidental edits.": "お好みのページ編集モードを選択してください(誤編集を防止します",
"Reading": "読み取り",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバー削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません",
"Member deleted successfully": "メンバー削除ました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "このメンバーを削除してもよろしいですか?この操作は取り消せません",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Move page to a different space.": "ページを別のスペースに移動します",
"Real-time editor connection lost. Retrying...": "リアルタイム編集の接続が切断されました。再接続中...",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次生成ます",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加すると目次生成されます",
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
@@ -398,13 +398,13 @@
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました",
"Share deleted successfully": "共有を削除しました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーに成功しました",
"Page duplicated successfully": "ページが正常に複製されました",
"Copy page to a different space.": "ページを別のスペースにコピーします",
"Page copied successfully": "ページコピーしました",
"Page duplicated successfully": "ページを複製しました",
"Find": "検索",
"Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
@@ -419,26 +419,26 @@
"Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証有効になりました",
"Two-factor authentication has been disabled": "二要素認証無効になりました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効するにはパスワードを入力してください",
"Two-factor authentication has been enabled": "二要素認証有効にました",
"Two-factor authentication has been disabled": "二要素認証無効にました",
"2-step verification": "2段階認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証でアカウントを保護します",
"Two-factor authentication is active on your account.": "二要素認証が有効です",
"Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード",
"Disable": "無効にする",
"Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコード生成されました",
"New backup codes have been generated": "新しいバックアップコード生成ました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコード再生成できます。これにより、既存のすべてのコードが無効になります",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、バックアップコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "新しいバックアップコードはいつでも再生成できます。既存のコードはすべて無効になります",
"Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効になりました",
"Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました",
@@ -449,51 +449,51 @@
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。もう一度お試しください",
"Backup": "バックアップ",
"Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "認証アプリアクセスできない場合、これらのコードでアカウントにアクセスできます。各コードは1回のみ使用可能です",
"Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定されました。再度ログインしてください",
"Two-factor authentication has been set up. Please log in again.": "二要素認証設定ました。再度ログインしてください",
"Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "ワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます",
"Your workspace requires two-factor authentication for all users": "このワークスペースではすべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースアクセスるには二要素認証を設定してください。アカウントのセキュリティが強化されます",
"Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます",
"Your workspace requires two-factor authentication. Please set it up to continue.": "このワークスペースでは二要素認証が必要です。続行するには設定してください",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "認証アプリからの確認コードアカウントのセキュリティが強化されます",
"Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Code must be exactly 6 digits": "コードは6桁で入力してください",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットが成功しました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットが成功しました。新しいパスワードでログインしてください",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントを作成しました。二要素認証を設定するためにログインしてください",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインし二要素認証を完了してください",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードリセットしました。新しいパスワードでログインして二要素認証を設定してください",
"Password reset was successful. Please log in with your new password.": "パスワードリセットしました。新しいパスワードでログインしてください",
"Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードを入力してください。各コードは1回のみ使用可能です",
"Verify": "確認",
"Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます",
"Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますか? この操作は元に戻せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}を完全に削除しますかこの操作は取り消せません",
"Restore '{{title}}' and its sub-pages?": "{{title}}とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元",
"Page moved to trash": "ページごみ箱に移動されました",
"Page restored successfully": "ページが正常に復元されました",
"Page moved to trash": "ページごみ箱に移動ました",
"Page restored successfully": "ページを復元しました",
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
@@ -511,10 +511,10 @@
"Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみSSOで登録できます",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインのメールアドレスを持つユーザーのみSSO経由で登録できます",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "有効にすると、すべてのメンバーが二要素認証を設定しないとワークスペースにアクセスできなくなります",
"Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名",
"Allow signup": "登録を許可する",
@@ -527,5 +527,47 @@
"Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成",
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています",
"Image removed successfully": "画像を削除しました",
"API key": "APIキー",
"API key created successfully": "APIキーを作成しました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "無効にしました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は取り消せません。このAPIキーを使用しているアプリケーションは動作しなくなります",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除"
}
@@ -527,5 +527,47 @@
"Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성",
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거"
}
@@ -34,7 +34,7 @@
"Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken",
"Create workspace": "Wwerkruimte aanmaken",
"Create workspace": "Werkruimte 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.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"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 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,5 +527,47 @@
"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"
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie",
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen"
}
@@ -527,5 +527,47 @@
"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}}"
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}",
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor"
}
@@ -498,10 +498,10 @@
"Deleted at": "Удалено в",
"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,5 +527,47 @@
"Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}",
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет"
}
@@ -527,5 +527,47 @@
"Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}",
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір"
}
@@ -527,5 +527,47 @@
"Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置",
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色"
}
@@ -37,14 +37,18 @@ export async function askAi(
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
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: ")) {
@@ -5,6 +5,7 @@ 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;
@@ -37,7 +38,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => {
if (props.editor.isEditable) {
setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`,
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`,
);
} else {
setPreview(
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
};
return (
<NodeViewWrapper>
<NodeViewWrapper data-drag-handle>
<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>
<NodeViewWrapper data-drag-handle>
{embedUrl ? (
<ResizableWrapper
initialHeight={nodeHeight || 480}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
type LibraryItems = any;
type LibraryPersistedData = {
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
load(metadata: { source: "load" | "save" }):
| Promise<{ libraryItems: LibraryItems } | null>
| {
libraryItems: LibraryItems;
}
libraryItems: LibraryItems;
}
| null;
save(libraryData: LibraryPersistedData): Promise<void> | void;
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
return JSON.parse(data);
}
} catch (e) {
console.error("Error downloading Excalidraw library from localStorage", e);
console.error(
"Error downloading Excalidraw library from localStorage",
e,
);
}
return null;
},
@@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
}
},
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const IV_LENGTH_BYTES = 12;
// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package
// Plugin: vite:import-analysis
// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7
// 7 | decryptData,
// 8 | encryptData
// 9 | } from "@excalidraw/excalidraw/data/encryption";
//@ts-ignore
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
true, // extractable
["encrypt", "decrypt"],
);
return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
};
export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string | CryptoKey,
//@ts-ignore
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
//@ts-ignore
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
//@ts-ignore
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
//@ts-ignore
iv: Uint8Array<ArrayBuffer>,
//@ts-ignore
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
@@ -8,13 +8,14 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState } from "react";
import { useState, useCallback } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useExcalidrawCollab } from "./use-excalidraw-collab";
const Excalidraw = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const pageId = editor.storage?.pageId;
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
const handleChange = useCallback(
(elements: readonly ExcalidrawElement[]) => {
broadcastScene(elements);
},
[broadcastScene],
);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
@@ -118,7 +130,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
};
return (
<NodeViewWrapper>
<NodeViewWrapper data-drag-handle>
<ReactClearModal
style={{
backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -157,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
scrollToContent: true,
}}
theme={computedColorScheme}
onChange={handleChange}
onPointerUpdate={broadcastPointer}
renderTopRightUI={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/>
</Suspense>
</div>
@@ -0,0 +1,257 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import type { TCollabClass } from "./Collab";
import type { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(collab: TCollabClass) {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SUBTYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: SocketId[]) => {
this.collab.setCollaborators(clients);
});
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
return !!(
this.socketInitialized &&
this.socket &&
this.roomId &&
this.roomKey
);
}
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error: any) {
if (error.name !== "AbortError") {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
}
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
captureUpdate: CaptureUpdateAction.NEVER,
});
}
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
payload: {
elements: syncableElements,
},
};
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
syncableElement.id,
syncableElement.version,
);
}
this.queueFileUpload();
await this._broadcastSocketData(data as SocketUpdateData);
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: WS_SUBTYPES.IDLE_STATUS,
payload: {
socketId: this.socket.id as SocketId,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: WS_SUBTYPES.MOUSE_LOCATION,
payload: {
socketId: this.socket.id as SocketId,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastVisibleSceneBounds = (
payload: {
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
payload: {
socketId: this.socket.id as SocketId,
username: this.collab.state.username,
sceneBounds: payload.sceneBounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
}
export default Portal;
@@ -0,0 +1,266 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { useAtom } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import type {
ExcalidrawImperativeAPI,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
import throttle from "lodash.throttle";
// Message types for collaboration
type SceneUpdateMessage = {
type: "SCENE_UPDATE";
payload: { elements: readonly ExcalidrawElement[] };
};
type PointerUpdateMessage = {
type: "POINTER_UPDATE";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
username: string;
selectedElementIds: Record<string, boolean>;
};
};
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
export function useExcalidrawCollab(
excalidrawAPI: ExcalidrawImperativeAPI | null,
pageId: string | undefined,
isOpen: boolean,
) {
const [socket] = useAtom(socketAtom);
const [currentUser] = useAtom(currentUserAtom);
const lastBroadcastedVersion = useRef(-1);
const isInitialized = useRef(false);
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
const [isCollaborating, setIsCollaborating] = useState(false);
// Track broadcasted element versions for bandwidth optimization
const broadcastedElementVersions = useRef<Map<string, number>>(new Map());
const roomId = pageId ? `excalidraw-${pageId}` : null;
const username = currentUser?.user?.name || "Anonymous";
// Broadcast pointer/cursor updates (volatile - can be dropped)
const broadcastPointer = useMemo(
() =>
throttle(
(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => {
if (!socket || !roomId || !isInitialized.current) return;
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
const data: PointerUpdateMessage = {
type: "POINTER_UPDATE",
payload: {
socketId: socket.id!,
pointer: payload.pointer,
button: payload.button,
username,
selectedElementIds:
excalidrawAPI?.getAppState().selectedElementIds || {},
},
};
const json = JSON.stringify(data);
socket.emit("ex-server-volatile-broadcast", [roomId, json, null]);
},
50,
),
[socket, roomId, username, excalidrawAPI],
);
// Broadcast scene changes with bandwidth optimization
const broadcastScene = useCallback(
(elements: readonly ExcalidrawElement[], syncAll = false) => {
if (!socket || !roomId || !isInitialized.current) {
return;
}
const sceneVersion = getSceneVersion(elements);
if (sceneVersion <= lastBroadcastedVersion.current) {
return;
}
// Filter to only send elements that changed since last broadcast
const changedElements = elements.filter((element) => {
const lastVersion = broadcastedElementVersions.current.get(element.id);
return syncAll || lastVersion === undefined || element.version > lastVersion;
});
if (changedElements.length === 0) {
return;
}
const data: SceneUpdateMessage = {
type: "SCENE_UPDATE",
payload: { elements: changedElements },
};
// Update tracking map
for (const element of changedElements) {
broadcastedElementVersions.current.set(element.id, element.version);
}
const json = JSON.stringify(data);
socket.emit("ex-server-broadcast", [roomId, json, null]);
lastBroadcastedVersion.current = sceneVersion;
},
[socket, roomId],
);
// Throttled version for onChange handler
const throttledBroadcastScene = useMemo(
() => throttle((elements: readonly ExcalidrawElement[]) => broadcastScene(elements, false), 100),
[broadcastScene],
);
// Handle incoming broadcasts
const handleClientBroadcast = useCallback(
(jsonData: string, _iv: Uint8Array | null) => {
if (!excalidrawAPI || !socket) return;
try {
const data: CollabMessage = JSON.parse(jsonData);
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
const remoteElements = data.payload.elements;
const localElements =
excalidrawAPI.getSceneElementsIncludingDeleted();
const reconciledElements = reconcileElements(
localElements,
// @ts-ignore
remoteElements,
excalidrawAPI.getAppState(),
);
excalidrawAPI.updateScene({
elements: reconciledElements,
});
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
} else if (data.type === "POINTER_UPDATE") {
const { socketId, pointer, button, username, selectedElementIds } =
data.payload;
// Don't update our own cursor
if (socketId === socket.id) return;
// Update collaborator with pointer info
const collaborator = collaboratorsRef.current.get(socketId) || {};
collaboratorsRef.current.set(socketId, {
...collaborator,
// @ts-ignore
pointer,
button,
username,
// @ts-ignore
selectedElementIds,
isCurrentUser: false,
});
excalidrawAPI.updateScene({
// @ts-ignore
collaborators: collaboratorsRef.current,
});
}
} catch (err) {
console.error("Failed to process broadcast:", err);
}
},
[excalidrawAPI, socket],
);
// Handle room user changes
const handleRoomUserChange = useCallback(
(socketIds: string[]) => {
if (!excalidrawAPI || !socket) return;
// Update collaborators map, preserving existing data
const newCollaborators = new Map<string, Collaborator>();
for (const id of socketIds) {
const existing = collaboratorsRef.current.get(id);
newCollaborators.set(id, {
...existing,
isCurrentUser: id === socket.id,
username:
existing?.username || (id === socket.id ? username : "User"),
});
}
collaboratorsRef.current = newCollaborators;
// @ts-ignore
excalidrawAPI.updateScene({ collaborators: newCollaborators });
// We're collaborating if there are other users
setIsCollaborating(socketIds.length > 1);
},
[excalidrawAPI, socket, username],
);
// Join/leave room based on modal state
useEffect(() => {
if (!socket || !roomId || !isOpen) {
setIsCollaborating(false);
return;
}
console.log("Joining room:", roomId);
socket.emit("ex-join-room", roomId);
isInitialized.current = true;
// Set up listeners
socket.on("ex-client-broadcast", handleClientBroadcast);
socket.on("ex-room-user-change", handleRoomUserChange);
socket.on("ex-first-in-room", () => {
console.log("First in excalidraw room");
});
socket.on("ex-new-user", (socketId: string) => {
console.log("New user joined:", socketId);
if (excalidrawAPI) {
// Send full scene to new user (syncAll = true)
broadcastScene(excalidrawAPI.getSceneElements(), true);
}
});
return () => {
console.log("Leaving room:", roomId);
socket.emit("ex-leave-room", roomId);
socket.off("ex-client-broadcast", handleClientBroadcast);
socket.off("ex-room-user-change", handleRoomUserChange);
socket.off("ex-first-in-room");
socket.off("ex-new-user");
isInitialized.current = false;
lastBroadcastedVersion.current = -1;
broadcastedElementVersions.current = new Map();
collaboratorsRef.current = new Map();
setIsCollaborating(false);
};
}, [
socket,
roomId,
isOpen,
handleClientBroadcast,
handleRoomUserChange,
broadcastScene,
excalidrawAPI,
]);
return {
broadcastScene: throttledBroadcastScene,
broadcastPointer,
isCollaborating,
};
}
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
}, [align]);
return (
<NodeViewWrapper>
<NodeViewWrapper data-drag-handle>
<Image
radius="md"
fit="contain"
@@ -1,19 +1,21 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useParams } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams();
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const {
data: page,
isLoading,
@@ -23,6 +25,20 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
const handleClick = (e: React.MouseEvent) => {
if (isSamePage && anchorId) {
e.preventDefault();
const element = document.querySelector(`[id="${anchorId}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`#${anchorId}`, { replace: true });
}
}
};
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
@@ -31,7 +47,7 @@ export default function MentionView(props: NodeViewProps) {
});
return (
<NodeViewWrapper style={{ display: "inline" }}>
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
{entityType === "user" && (
<Text className={classes.userMention} component="span">
@{label}
@@ -45,6 +61,7 @@ export default function MentionView(props: NodeViewProps) {
to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
@@ -20,7 +20,6 @@ import {
IconCalendar,
IconAppWindow,
IconSitemap,
IconPageBreak,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -154,19 +153,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Page break",
description: "Insert page break",
searchTerms: ["page break", "hr"],
icon: IconPageBreak,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent('<hr data-type="pagebreak" /><p></p>')
.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>
<NodeViewWrapper data-drag-handle>
<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>
<NodeViewWrapper data-drag-handle>
<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>
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
@@ -15,7 +15,7 @@ export default function VideoView(props: NodeViewProps) {
}, [align]);
return (
<NodeViewWrapper>
<NodeViewWrapper data-drag-handle>
<video
preload="metadata"
width={width}
@@ -46,7 +46,6 @@ import {
Heading,
Highlight,
UniqueID,
HorizontalRule,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -109,9 +108,7 @@ export const mainExtensions = [
spellcheck: false,
},
},
horizontalRule: false,
}),
HorizontalRule,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
@@ -110,14 +110,6 @@
border-top: 1px solid #68cef8;
}
hr[data-type="pagebreak"] {
border-top: 1px dashed var(--mantine-color-dark-2) !important;
}
.ProseMirror[contenteditable="false"] hr[data-type="pagebreak"] {
display: none !important;
}
.ProseMirror-selectednode {
outline: 2px solid #70cff8;
}
@@ -194,6 +186,7 @@
margin-left: auto;
margin-right: auto;
}
}
.ProseMirror > h1,
@@ -202,11 +195,13 @@
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
@@ -218,7 +213,7 @@
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
@@ -20,10 +20,4 @@
.tableWrapper {
overflow: hidden !important;
}
hr[data-type="pagebreak"] {
break-before: always;
page-break-before: always;
visibility: hidden;
}
}
@@ -1,5 +1,7 @@
import api from "@/lib/api-client";
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IApiKey } from "@/ee/api-key";
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
const req = await api.post<IFileTask>("/file-tasks/info", {
@@ -8,7 +10,10 @@ export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
return req.data;
}
export async function getFileTasks(): Promise<IFileTask[]> {
const req = await api.post<IFileTask[]>("/file-tasks");
export async function getFileTasks(
params?: QueryParams,
): Promise<IPagination<IFileTask>> {
const req = await api.post("/file-tasks", { ...params });
return req.data;
}
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
name: z.string().trim().min(2).max(100),
description: z.string().max(500),
});
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(50),
name: z.string().min(2).max(100),
description: z.string().max(500),
});
@@ -50,7 +50,7 @@ export default function GroupList() {
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
@@ -269,12 +269,15 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const prefetchPage = () => {
timerRef.current = setTimeout(() => {
queryClient.prefetchQuery({
queryKey: ["pages", node.data.slugId],
queryFn: () => getPageById({ pageId: node.data.slugId }),
timerRef.current = setTimeout(async () => {
const page = await queryClient.fetchQuery({
queryKey: ["pages", node.data.id],
queryFn: () => getPageById({ pageId: node.data.id }),
staleTime: 5 * 60 * 1000,
});
if (page?.slugId) {
queryClient.setQueryData(["pages", page.slugId], page);
}
}, 150);
};
@@ -8,7 +8,6 @@ import {
Switch,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconExternalLink, IconWorld, IconLock } from "@tabler/icons-react";
import React, { useEffect, useMemo, useState } from "react";
@@ -21,12 +20,12 @@ import {
import { Link, useNavigate, useParams } from "react-router-dom";
import { extractPageSlugId, getPageIcon } from "@/lib";
import { useTranslation } from "react-i18next";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
interface ShareModalProps {
readOnly: boolean;
@@ -35,7 +34,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug } = useParams();
const pageId = extractPageSlugId(pageSlug);
const pageSlugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams();
const { isTrial } = useTrial();
@@ -27,9 +27,7 @@ import {
getShares,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { useEffect } from "react";
export function useGetSharesQuery(
params?: QueryParams,
@@ -72,7 +70,7 @@ export function useShareForPageQuery(
queryKey: ["share-for-page", pageId],
queryFn: () => getShareForPage(pageId),
enabled: !!pageId,
staleTime: 0,
staleTime: 60 * 1000,
retry: false,
});
@@ -1,6 +1,7 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
@@ -9,12 +10,12 @@ import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
name: z.string().trim().min(2).max(100),
slug: z
.string()
.trim()
.min(2)
.max(50)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -7,12 +7,12 @@ import { ISpace } from "@/features/space/types/space.types.ts";
import { useTranslation } from "react-i18next";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(250),
name: z.string().min(2).max(100),
description: z.string().max(500),
slug: z
.string()
.min(2)
.max(50)
.max(100)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
@@ -62,14 +62,17 @@ export default function SpaceSettingsModal({
</Tabs.List>
<Tabs.Panel value="general">
<ScrollArea h={550} scrollbarSize={4} pr={8}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px"}}>
<SpaceDetails
spaceId={space?.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
@@ -48,7 +48,7 @@ export default function SpaceList() {
variant="filled"
name={space.name}
/>
<div>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</Text>
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
return (
<>
<SearchInput onSearch={handleSearch} />
<ScrollArea h={400}>
<ScrollArea h={450}>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}>
<Table.Thead>
+26 -26
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.23.2",
"version": "0.24.1",
"description": "",
"author": "",
"private": true,
@@ -30,48 +30,48 @@
"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",
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0",
"@casl/ability": "^6.7.3",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@langchain/core": "1.1.13",
"@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.9",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/core": "^11.1.11",
"@nestjs/event-emitter": "^3.0.1",
"@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.11",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.9",
"@nestjs/websockets": "^11.1.11",
"@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",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3",
"cheerio": "^1.1.0",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie": "^1.0.2",
"fs-extra": "^11.3.0",
"happy-dom": "20.0.10",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
@@ -79,9 +79,9 @@
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.11",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.0",
"otpauth": "^9.4.1",
"p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
@@ -95,11 +95,11 @@
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
"typesense": "^2.1.0",
"ws": "^8.18.3",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
},
"devDependencies": {
@@ -124,7 +124,7 @@
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"kysely-codegen": "^0.17.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "3.0.2",
"source-map-support": "^0.5.21",
@@ -36,7 +36,6 @@ import {
Highlight,
UniqueID,
addUniqueIdsToDoc,
HorizontalRule,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -49,9 +48,7 @@ export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
heading: false,
horizontalRule: false,
}),
HorizontalRule,
Heading,
UniqueID.configure({
types: ['heading', 'paragraph'],
@@ -11,7 +11,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateGroupDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
+7 -10
View File
@@ -74,16 +74,13 @@ export class SearchService {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(
opts.userId,
);
if (userSpaceIds.length > 0) {
queryResults = queryResults
.where('spaceId', 'in', userSpaceIds)
.where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
+53 -51
View File
@@ -69,8 +69,8 @@ export class ShareService {
return await this.shareRepo.insertShare({
key: nanoIdGen().toLowerCase(),
pageId: page.id,
includeSubPages: createShareDto.includeSubPages || true,
searchIndexing: createShareDto.searchIndexing || true,
includeSubPages: createShareDto.includeSubPages ?? false,
searchIndexing: createShareDto.searchIndexing ?? false,
creatorId: authUserId,
spaceId: page.spaceId,
workspaceId,
@@ -123,80 +123,82 @@ export class ShareService {
.withRecursive('page_hierarchy', (cte) =>
cte
.selectFrom('pages')
.leftJoin('shares', 'shares.pageId', 'pages.id')
.select([
'id',
'slugId',
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'parentPageId',
'pages.parentPageId',
sql`0`.as('level'),
'shares.id as shareId',
'shares.key as shareKey',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
// Increase the level by 1 for each ancestor.
sql`ph.level + 1`.as('level'),
])
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
.where(isValidUUID(pageId) ? 'pages.id' : 'pages.slugId', '=', pageId)
.where('pages.deletedAt', 'is', null)
.unionAll(
(union) =>
union
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id')
.leftJoin('shares as s', 's.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.parentPageId',
sql`ph.level + 1`.as('level'),
's.id as shareId',
's.key as shareKey',
's.includeSubPages',
's.searchIndexing',
's.creatorId',
's.spaceId',
's.workspaceId',
's.createdAt',
])
.where('p.deletedAt', 'is', null)
.where(sql`ph.share_id`, 'is', null) // stop if share found
.where(sql`ph.level`, '<', sql`25`), // prevent loop
),
)
.selectFrom('page_hierarchy')
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
.select([
'page_hierarchy.id as sharedPageId',
'page_hierarchy.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'shares.creatorId',
'shares.spaceId',
'shares.workspaceId',
'shares.createdAt',
'shares.updatedAt',
])
.where('shares.id', 'is not', null)
.orderBy('page_hierarchy.level', 'asc')
.selectAll()
.where('shareId', 'is not', null)
.limit(1)
.executeTakeFirst();
if (!share || share.workspaceId != workspaceId) {
if (!share || share.workspaceId !== workspaceId) {
return undefined;
}
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
if ((share.level as number) > 0 && !share.includeSubPages) {
return undefined;
}
return {
id: share.id,
key: share.key,
id: share.shareId,
key: share.shareKey,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.pageId,
pageId: share.id,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
id: share.id,
slugId: share.slugId,
title: share.title,
icon: share.icon,
},
};
}
@@ -9,7 +9,7 @@ import {Transform, TransformFnParams} from "class-transformer";
export class CreateSpaceDto {
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsString()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -19,7 +19,7 @@ export class CreateSpaceDto {
description?: string;
@MinLength(2)
@MaxLength(50)
@MaxLength(100)
@IsAlphanumeric()
slug: string;
}
@@ -293,24 +293,18 @@ export class PageRepo {
}
async getRecentPages(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
@@ -137,25 +137,19 @@ export class ShareRepo {
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
@@ -209,34 +209,33 @@ export class SpaceMemberRepo {
return roles;
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.db
getUserSpaceIdsQuery(userId: string) {
return this.db
.selectFrom('spaceMembers')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('userId', '=', userId)
.union(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.innerJoin('spaces', 'spaces.id', 'spaceMembers.spaceId')
.select(['spaces.id'])
.select('spaces.id')
.where('groupUsers.userId', '=', userId),
)
.execute();
);
}
async getUserSpaceIds(userId: string): Promise<string[]> {
const membership = await this.getUserSpaceIdsQuery(userId).execute();
return membership.map((space) => space.id);
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.getUserSpaceIds(userId);
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
//.where('workspaceId', '=', workspaceId)
.where('id', 'in', userSpaceIds)
.where('id', 'in', this.getUserSpaceIdsQuery(userId))
.orderBy('createdAt', 'asc');
if (pagination.query) {
@@ -253,14 +252,9 @@ export class SpaceMemberRepo {
);
}
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
}
@@ -105,7 +105,7 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'gemini', 'ollama'])
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@IsString()
AI_DRIVER: string;
@@ -117,11 +117,10 @@ export class EnvironmentVariables {
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536'])
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@@ -129,13 +128,20 @@ export class EnvironmentVariables {
AI_COMPLETION_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER && ['openai', 'openai-compatible'].includes(obj.AI_DRIVER),
)
@IsString()
@IsNotEmpty()
OPENAI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.OPENAI_API_URL && obj.AI_DRIVER === 'openai')
@ValidateIf(
(obj) =>
obj.AI_DRIVER === 'openai-compatible' ||
(obj.AI_DRIVER === 'openai' && obj.OPENAI_API_URL),
)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@@ -10,46 +10,59 @@ import {
} from '@nestjs/common';
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User } from '@docmost/db/types/entity.types';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../core/casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../../core/casl/abilities/workspace-ability.factory';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@Controller('file-tasks')
export class FileTaskController {
constructor(
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceMemberRepo: SpaceMemberRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post()
async getFileTasks(@AuthUser() user: User) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (!userSpaceIds || userSpaceIds.length === 0) {
return [];
async getFileTasks(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
const fileTasks = await this.db
const query = this.db
.selectFrom('fileTasks')
.selectAll()
.where('spaceId', 'in', userSpaceIds)
.execute();
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id))
.orderBy('createdAt', 'desc');
if (!fileTasks) {
throw new NotFoundException('File task not found');
}
return fileTasks;
return executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
}
@UseGuards(JwtAuthGuard)
+5 -3
View File
@@ -15,10 +15,12 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
maxParamLength: 1000,
trustProxy: true,
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
},
}),
{
rawBody: true,
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { ExcalidrawFollowPayload } from '../types/excalidraw.types';
@Injectable()
export class ExcalidrawCollabService {
// Track socket -> rooms mapping for disconnect handling
// (Socket.IO clears client.rooms before handleDisconnect runs)
private socketRooms = new Map<string, Set<string>>();
async handleJoinRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.join(roomId);
// Track room membership
if (!this.socketRooms.has(client.id)) {
this.socketRooms.set(client.id, new Set());
}
this.socketRooms.get(client.id).add(roomId);
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length <= 1) {
server.to(client.id).emit('ex-first-in-room');
} else {
client.broadcast.to(roomId).emit('ex-new-user', client.id);
}
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
async handleLeaveRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.leave(roomId);
// Remove from tracking
this.socketRooms.get(client.id)?.delete(roomId);
// Notify remaining users
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length > 0) {
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
}
handleServerBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.broadcast.to(roomId).emit('ex-client-broadcast', encryptedData, iv);
}
handleServerVolatileBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.volatile.broadcast
.to(roomId)
.emit('ex-client-broadcast', encryptedData, iv);
}
async handleUserFollow(
client: Socket,
server: Server,
payload: ExcalidrawFollowPayload,
): Promise<void> {
const roomId = `follow@${payload.userToFollow.socketId}`;
if (payload.action === 'FOLLOW') {
await client.join(roomId);
} else {
await client.leave(roomId);
}
const sockets = await server.in(roomId).fetchSockets();
const followedBy = sockets.map((socket) => socket.id);
server.to(payload.userToFollow.socketId).emit(
'ex-user-follow-room-change',
followedBy,
);
}
async handleDisconnecting(client: Socket, server: Server): Promise<void> {
// Use tracked rooms since client.rooms is empty by this point
const rooms = this.socketRooms.get(client.id) || new Set();
for (const roomId of rooms) {
const otherClients = (await server.in(roomId).fetchSockets()).filter(
(socket) => socket.id !== client.id,
);
const isFollowRoom = roomId.startsWith('follow@');
if (!isFollowRoom && otherClients.length > 0) {
server.to(roomId).emit(
'ex-room-user-change',
otherClients.map((socket) => socket.id),
);
}
if (isFollowRoom && otherClients.length === 0) {
const socketId = roomId.replace('follow@', '');
server.to(socketId).emit('ex-broadcast-unfollow');
}
}
// Clean up tracking
this.socketRooms.delete(client.id);
}
}
@@ -0,0 +1,9 @@
export type ExcalidrawUserToFollow = {
socketId: string;
username: string;
};
export type ExcalidrawFollowPayload = {
userToFollow: ExcalidrawUserToFollow;
action: 'FOLLOW' | 'UNFOLLOW';
};
+80 -1
View File
@@ -1,6 +1,8 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import * as cookie from 'cookie';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
@WebSocketGateway({
cors: { origin: '*' },
transports: ['websocket'],
})
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
export class WsGateway
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
{
@WebSocketServer()
server: Server;
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private excalidrawCollabService: ExcalidrawCollabService,
) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
@@ -41,6 +49,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
this.server.to(client.id).emit('init-room');
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
client.leave(roomName);
}
// Excalidraw Sync
@SubscribeMessage('ex-join-room')
async handleExJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleJoinRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-leave-room')
async handleExLeaveRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleLeaveRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-server-broadcast')
handleServerBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-server-volatile-broadcast')
handleServerVolatileBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerVolatileBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-user-follow')
async handleUserFollow(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ExcalidrawFollowPayload,
): Promise<void> {
await this.excalidrawCollabService.handleUserFollow(
client,
this.server,
payload,
);
}
async handleDisconnect(client: Socket): Promise<void> {
await this.excalidrawCollabService.handleDisconnecting(client, this.server);
}
onModuleDestroy() {
if (this.server) {
this.server.close();
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { TokenModule } from '../core/auth/token.module';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
@Module({
imports: [TokenModule],
providers: [WsGateway],
providers: [WsGateway, ExcalidrawCollabService],
})
export class WsModule {}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.23.2",
"version": "0.24.1",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -19,6 +19,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "^6.7.5",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.3",
@@ -38,7 +39,6 @@
"@tiptap/extension-heading": "2.27.1",
"@tiptap/extension-highlight": "2.27.1",
"@tiptap/extension-history": "2.27.1",
"@tiptap/extension-horizontal-rule": "2.27.1",
"@tiptap/extension-image": "2.27.1",
"@tiptap/extension-link": "2.27.1",
"@tiptap/extension-list-item": "2.27.1",
@@ -101,6 +101,7 @@
},
"overrides": {
"jsdom": "25.0.1",
"jsonwebtoken": "9.0.3",
"y-prosemirror": "1.3.7"
},
"neverBuiltDependencies": []
-1
View File
@@ -23,4 +23,3 @@ export * from "./lib/subpages";
export * from "./lib/highlight";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/hr";
-21
View File
@@ -1,21 +0,0 @@
import { HorizontalRule as TiptapHorizontalRule } from "@tiptap/extension-horizontal-rule";
export type HorizontalRuleType = "pageBreak";
export const HorizontalRule = TiptapHorizontalRule.extend({
addAttributes() {
return {
type: {
default: null,
parseHTML: (element) => element.getAttribute("data-type"),
renderHTML: (attributes) => {
if (attributes.type) {
return {
"data-type": attributes.type,
};
}
},
},
};
},
});
+1
View File
@@ -165,6 +165,7 @@ export const Mention = Node.create<MentionOptions>({
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes() {
return {
@@ -28,7 +28,7 @@ export const Subpages = Node.create<SubpagesOptions>({
group: "block",
atom: true,
draggable: false,
draggable: true,
parseHTML() {
return [
+1 -2
View File
@@ -3,7 +3,6 @@ import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
import { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model";
import Table from "@tiptap/extension-table";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
import { customAlphabet } from "nanoid";
@@ -289,7 +288,7 @@ export const isColumnGripSelected = ({
const node = nodeDOM || domAtPos;
if (
!editor.isActive(Table.name) ||
!editor.isActive("table") ||
!node ||
isTableSelected(state.selection)
) {
+760 -662
View File
File diff suppressed because it is too large Load Diff