diff --git a/apps/client/package.json b/apps/client/package.json index 3a658caf..443fa4e6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.20.4", + "version": "0.21.0", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -15,45 +15,46 @@ "@docmost/editor-ext": "workspace:*", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", - "@excalidraw/excalidraw": "^0.17.6", + "@excalidraw/excalidraw": "0.18.0-864353b", "@mantine/core": "^7.17.0", "@mantine/form": "^7.17.0", "@mantine/hooks": "^7.17.0", "@mantine/modals": "^7.17.0", "@mantine/notifications": "^7.17.0", "@mantine/spotlight": "^7.17.0", - "@tabler/icons-react": "^3.22.0", - "@tanstack/react-query": "^5.61.4", - "@tiptap/extension-character-count": "^2.11.5", - "axios": "^1.8.4", + "@tabler/icons-react": "^3.34.0", + "@tanstack/react-query": "^5.80.6", + "@tiptap/extension-character-count": "^2.10.3", + "alfaaz": "^1.1.0", + "axios": "^1.9.0", "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.1", + "jotai": "^2.12.5", "jotai-optics": "^0.4.0", "js-cookie": "^3.0.5", "jwt-decode": "^4.0.0", - "katex": "0.16.21", - "lowlight": "^3.2.0", - "mermaid": "^11.4.1", + "katex": "0.16.22", + "lowlight": "^3.3.0", + "mermaid": "^11.6.0", "mitt": "^3.0.1", "react": "^18.3.1", "react-arborist": "3.4.0", - "react-clear-modal": "^2.0.11", + "react-clear-modal": "^2.0.15", "react-dom": "^18.3.1", "react-drawio": "^1.0.1", "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.1", + "semver": "^7.7.2", "socket.io-client": "^4.8.1", "tippy.js": "^6.3.7", "tiptap-extension-global-drag-handle": "^0.1.18", - "zod": "^3.23.8" + "zod": "^3.25.56" }, "devDependencies": { "@eslint/js": "^9.16.0", @@ -77,6 +78,6 @@ "prettier": "^3.4.1", "typescript": "^5.7.2", "typescript-eslint": "^8.17.0", - "vite": "^6.3.2" + "vite": "^6.3.5" } } diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 3ba481d1..df563db7 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier", "Share deleted successfully": "Freigabe erfolgreich gelöscht", "Share not found": "Freigabe nicht gefunden", - "Failed to share page": "Fehler beim Teilen der Seite" + "Failed to share page": "Fehler beim Teilen der Seite", + "Copy page": "Seite kopieren", + "Copy page to a different space.": "Seite in einen anderen Bereich kopieren.", + "Page copied successfully": "Seite erfolgreich kopiert" } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 2d6b2303..17d37171 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -355,6 +355,9 @@ "Character count: {{characterCount}}": "Character count: {{characterCount}}", "New update": "New update", "{{latestVersion}} is available": "{{latestVersion}} is available", + "Default page edit mode": "Default page edit mode", + "Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.", + "Reading": "Reading" "Delete member": "Delete member", "Member deleted successfully": "Member deleted successfully", "Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.", @@ -385,7 +388,7 @@ "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", "Failed to share page": "Failed to share page", - "Copy page": "Copy page", + "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index eed09b2c..b1bf62e6 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí", "Share deleted successfully": "Compartición eliminada con éxito", "Share not found": "Compartición no encontrada", - "Failed to share page": "Error al compartir la página" + "Failed to share page": "Error al compartir la página", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 4a8b32f7..9885dc51 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici", "Share deleted successfully": "Partage supprimé avec succès", "Share not found": "Partage non trouvé", - "Failed to share page": "Échec du partage de la page" + "Failed to share page": "Échec du partage de la page", + "Copy page": "Copier la page", + "Copy page to a different space.": "Copier la page dans un autre espace.", + "Page copied successfully": "Page copiée avec succès" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 28eb6771..e0b6db7e 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui", "Share deleted successfully": "Condivisione eliminata con successo", "Share not found": "Condivisione non trovata", - "Failed to share page": "Condivisione della pagina fallita" + "Failed to share page": "Condivisione della pagina fallita", + "Copy page": "Copia pagina", + "Copy page to a different space.": "Copia pagina in un altro spazio.", + "Page copied successfully": "Pagina copiata con successo" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index cba12e63..6ab411de 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -347,7 +347,7 @@ "Members added successfully": "メンバーを追加しました", "Member removed successfully": "メンバーが削除されました", "Member role updated successfully": "メンバーのロールを更新しました", - "Created by: {{creatorName}}": "作成者: {{creatorName}}", + "Created by: {{creatorName}}": "作成者: {{creatorName}}", "Created at: {{time}}": "が作成しました:{{time}}", "Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}", "Word count: {{wordCount}}": "ワード数: {{wordCount}}", @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます", "Share deleted successfully": "共有が正常に削除されました", "Share not found": "共有が見つかりません", - "Failed to share page": "ページの共有に失敗しました" + "Failed to share page": "ページの共有に失敗しました", + "Copy page": "ページをコピー", + "Copy page to a different space.": "ページを別のスペースにコピーします。", + "Page copied successfully": "ページのコピーに成功しました" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index fd8db95b..45a79e92 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -58,7 +58,7 @@ "Enter a strong password": "강력한 비밀번호를 입력하세요", "Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]", "enter valid emails addresses": "유효한 이메일 주소를 입력하세요", - "Enter your current password": "현재 비밀번호를 입력하세요", + "Enter your current password": "기존 비밀번호를 입력하세요", "enter your full name": "전체 이름을 입력하세요", "Enter your new password": "새 비밀번호를 입력하세요", "Enter your new preferred email": "새로운 이메일을 입력하세요", @@ -170,7 +170,7 @@ "Successfully restored": "복원 완료", "System settings": "시스템 설정", "Theme": "배경", - "To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.", + "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": "제목 없음", @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다", "Share deleted successfully": "공유가 성공적으로 삭제되었습니다", "Share not found": "공유를 찾을 수 없습니다", - "Failed to share page": "페이지 공유에 실패했습니다" + "Failed to share page": "페이지 공유에 실패했습니다", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 1879078e..0ba3631a 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier", "Share deleted successfully": "Delen succesvol verwijderd", "Share not found": "Delen niet gevonden", - "Failed to share page": "Pagina delen mislukt" + "Failed to share page": "Pagina delen mislukt", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 0986a2b7..3b6be52c 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui", "Share deleted successfully": "Compartilhamento excluído com sucesso", "Share not found": "Compartilhamento não encontrado", - "Failed to share page": "Falha ao compartilhar página" + "Failed to share page": "Falha ao compartilhar página", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 27105bfc..92677ff4 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь", "Share deleted successfully": "Общий доступ успешно удален", "Share not found": "Общий доступ не найден", - "Failed to share page": "Не удалось поделиться страницей" + "Failed to share page": "Не удалось поделиться страницей", + "Copy page": "Копировать страницу", + "Copy page to a different space.": "Копировать страницу в другое пространство.", + "Page copied successfully": "Страница успешно скопирована" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json new file mode 100644 index 00000000..4585c028 --- /dev/null +++ b/apps/client/public/locales/uk-UA/translation.json @@ -0,0 +1,390 @@ +{ + "Account": "Обліковий запис", + "Active": "Активний", + "Add": "Додати", + "Add group members": "Додати учасників групи", + "Add groups": "Додати групи", + "Add members": "Додати учасників", + "Add to groups": "Додати до груп", + "Add space members": "Додати учасників простору", + "Admin": "Адміністратор", + "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.", + "Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?", + "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 edit": "Може редагувати", + "Can manage workspace": "Може керувати робочою областю", + "Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити", + "Can view": "Може переглядати", + "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.": "Оберіть бажану ширину сторінки.", + "Confirm": "Підтвердити", + "Copy link": "Копіювати посилання", + "Create": "Створити", + "Create group": "Створити групу", + "Create page": "Створити сторінку", + "Create space": "Створити простір", + "Create workspace": "Створити робочу область", + "Current password": "Поточний пароль", + "Dark": "Темна", + "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.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.", + "Description": "Опис", + "Details": "Деталі", + "e.g ACME": "наприклад, ACME", + "e.g ACME Inc": "наприклад, ACME Inc", + "e.g Developers": "наприклад, Розробники", + "e.g Group for developers": "наприклад, Група для розробників", + "e.g product": "наприклад, продукт", + "e.g Product Team": "наприклад, Продуктова команда", + "e.g Sales": "наприклад, Продажі", + "e.g Space for product team": "наприклад, Простір для продуктової команди", + "e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів", + "Edit": "Редагувати", + "Edit group": "Редагувати групу", + "Email": "Електронна пошта", + "Enter a strong password": "Введіть надійний пароль", + "Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]", + "enter valid emails addresses": "введіть дійсні адреси електронної пошти", + "Enter your current password": "Введіть ваш поточний пароль", + "enter your full name": "введіть ваше повне ім'я", + "Enter your new password": "Введіть ваш новий пароль", + "Enter your new preferred email": "Введіть вашу нову бажану електронну пошту", + "Enter your password": "Введіть ваш пароль", + "Error fetching page data.": "Помилка при завантаженні даних сторінки.", + "Error loading page history.": "Помилка при завантаженні історії сторінки.", + "Export": "Експорт", + "Failed to create page": "Не вдалося створити сторінку", + "Failed to delete page": "Не вдалося видалити сторінку", + "Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки", + "Failed to import pages": "Не вдалося імпортувати сторінки", + "Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.", + "Failed to update data": "Не вдалося оновити дані", + "Full access": "Повний доступ", + "Full page width": "Ширина на всю сторінку", + "Full width": "На всю ширину", + "General": "Загальні", + "Group": "Група", + "Group description": "Опис групи", + "Group name": "Назва групи", + "Groups": "Групи", + "Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.", + "Home": "Головна", + "Import pages": "Імпорт сторінок", + "Import pages & space settings": "Імпорт сторінок і налаштування простору", + "Importing pages": "Імпортування сторінок", + "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": "Запрошені учасники отримають доступ до просторів, доступ до яких має група", + "Join the workspace": "Приєднатися до робочої області", + "Language": "Мова", + "Light": "Світла", + "Link copied": "Посилання скопійовано", + "Login": "Увійти", + "Logout": "Вийти", + "Manage Group": "Керування групою", + "Manage members": "Керування учасниками", + "member": "учасник", + "Member": "Учасник", + "members": "учасники", + "Members": "Учасники", + "My preferences": "Мої налаштування", + "My Profile": "Мій профіль", + "My profile": "Мій профіль", + "Name": "Ім'я", + "New email": "Нова електронна адреса", + "New page": "Нова сторінка", + "New password": "Новий пароль", + "No group found": "Групу не знайдено", + "No page history saved yet.": "Історія сторінок ще не збережена.", + "No pages yet": "Сторінок поки немає", + "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.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", + "Pages": "Сторінки", + "pages": "сторінки", + "Password": "Пароль", + "Password changed successfully": "Пароль успішно змінено", + "Pending": "В очікуванні", + "Please confirm your action": "Будь ласка, підтвердіть вашу дію", + "Preferences": "Налаштування", + "Print PDF": "Друк PDF", + "Profile": "Профіль", + "Recently updated": "Нещодавно оновлено", + "Remove": "Видалити", + "Remove group member": "Видалити учасника групи", + "Remove space member": "Видалити учасника простору", + "Restore": "Відновити", + "Role": "Роль", + "Save": "Зберегти", + "Search": "Пошук", + "Search for groups": "Пошук груп", + "Search for users": "Пошук користувачів", + "Search for users and groups": "Пошук користувачів та груп", + "Search...": "Пошук...", + "Select language": "Оберіть мову", + "Select role": "Оберіть роль", + "Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників", + "Select theme": "Оберіть тему", + "Send invitation": "Надіслати запрошення", + "Invitation sent": "Запрошення надіслано", + "Settings": "Налаштування", + "Setup workspace": "Налаштувати робочу область", + "Sign In": "Вхід", + "Sign Up": "Реєстрація", + "Slug": "Slug", + "Space": "Простір", + "Space description": "Опис простору", + "Space menu": "Меню простору", + "Space name": "Назва простору", + "Space settings": "Налаштування простору", + "Space slug": "Slug простору", + "Spaces": "Простори", + "Spaces you belong to": "Простори, до яких ви належите", + "No space found": "Простори не знайдено", + "Search for spaces": "Пошук просторів", + "Start typing to search...": "Почніть вводити для пошуку...", + "Status": "Статус", + "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.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.", + "untitled": "без назви", + "Untitled": "Без назви", + "Updated successfully": "Оновлено успішно", + "User": "Користувач", + "Workspace": "Робоча область", + "Workspace Name": "Ім'я робочої області", + "Workspace settings": "Налаштування робочої області", + "You can change your password here.": "Ви можете змінити свій пароль тут.", + "Your Email": "Ваша електронна пошта", + "Your import is complete.": "Ваш імпорт завершено.", + "Your name": "Ваше ім'я", + "Your Name": "Ваше ім'я", + "Your password": "Ваш пароль", + "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.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.", + "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": "Надіслати посилання для скидання", + "Password reset": "Скидання пароля", + "Your new password": "Ваш новий пароль", + "Set password": "Встановити пароль", + "Write a comment": "Написати коментар", + "Reply...": "Відповісти...", + "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": "Коментар успішно оновлено", + "Failed to update comment": "Не вдалося оновити коментар", + "Comment deleted successfully": "Коментар успішно видалено", + "Failed to delete comment": "Не вдалося видалити коментар", + "Comment resolved successfully": "Коментар успішно вирішено", + "Failed to resolve comment": "Не вдалося вирішити коментар", + "Revoke invitation": "Відкликати запрошення", + "Revoke": "Відкликати", + "Don't": "Ні", + "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.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.", + "Invite link": "Посилання для запрошення", + "Copy": "Копіювати", + "Copied": "Скопійовано", + "Select a user": "Оберіть користувача", + "Select a group": "Оберіть групу", + "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.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.", + "Confirm space name": "Підтвердіть назву простору", + "Type the space name {{spaceName}} to confirm your action.": "Введіть назву простору {{spaceName}}, щоб підтвердити вашу дію.", + "Format": "Формат", + "Include subpages": "Включити вкладені сторінки", + "Include attachments": "Включити вкладення", + "Select export format": "Виберіть формат експорту", + "Export failed:": "Експортування не вдалося:", + "export error": "помилка експорту", + "Export page": "Експорт сторінки", + "Export space": "Експорт простору", + "Export {{type}}": "Експорт {{type}}", + "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", + "Align left": "По лівому краю", + "Align right": "По правому краю", + "Align center": "По центру", + "Justify": "По ширині", + "Merge cells": "Об'єднати комірки", + "Split cell": "Розділити комірку", + "Delete column": "Видалити стовпець", + "Delete row": "Видалити рядок", + "Add left column": "Додати стовпець ліворуч", + "Add right column": "Додати стовпець праворуч", + "Add row above": "Додати рядок вище", + "Add row below": "Додати рядок нижче", + "Delete table": "Видалити таблицю", + "Info": "Інформація", + "Success": "Успішно", + "Warning": "Попередження", + "Danger": "Важливо", + "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", + "Paste link": "Вставити посилання", + "Edit link": "Редагувати посилання", + "Remove link": "Видалити посилання", + "Add link": "Додати посилання", + "Please enter a valid url": "Будь ласка, введіть коректний url", + "Empty equation": "Порожнє рівняння", + "Invalid equation": "Неприпустиме рівняння", + "Color": "Колір", + "Text color": "Колір тексту", + "Default": "За замовчуванням", + "Blue": "Синій", + "Green": "Зелений", + "Purple": "Фіолетовий", + "Red": "Червоний", + "Yellow": "Жовтий", + "Orange": "Помаранчевий", + "Pink": "Рожевий", + "Gray": "Сірий", + "Embed link": "Вбудоване посилання", + "Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}", + "Embed {{provider}}": "Вбудувати {{provider}}", + "Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}", + "Bold": "Жирний", + "Italic": "Курсив", + "Underline": "Підкреслений", + "Strike": "Закреслений", + "Code": "Код", + "Comment": "Коментар", + "Text": "Текст", + "Heading 1": "Заголовок 1", + "Heading 2": "Заголовок 2", + "Heading 3": "Заголовок 3", + "To-do List": "Список справ", + "Bullet List": "Маркований список", + "Numbered List": "Нумерований список", + "Blockquote": "Блок цитування", + "Just start typing with plain text.": "Просто почніть друкувати звичайний текст.", + "Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.", + "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.": "Вставити блок, що згортається.", + "Video": "Відео", + "Divider": "Роздільник", + "Quote": "Цитата", + "Image": "Зображення", + "File attachment": "Прикріплений файл", + "Toggle block": "Блок, що згортається", + "Callout": "Виноска", + "Insert callout notice.": "Вставити виноску з повідомленням.", + "Math inline": "Формула", + "Insert inline math equation.": "Вставити математичне рівняння в рядок.", + "Math block": "Блок формул", + "Insert math equation": "Вставити математичне рівняння", + "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": "Перемкнути заголовок", + "Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд", + "Names do not match": "Назви не співпадають", + "Today, {{time}}": "Сьогодні, {{time}}", + "Yesterday, {{time}}": "Вчора, {{time}}", + "Space created successfully": "Простір успішно створено", + "Space updated successfully": "Простір успішно оновлено", + "Space deleted successfully": "Простір успішно видалено", + "Members added successfully": "Учасників успішно додано", + "Member removed successfully": "Учасника успішно видалено", + "Member role updated successfully": "Роль учасника успішно оновлено", + "Created by: {{creatorName}}": "Автор: {{creatorName}}", + "Created at: {{time}}": "Дата створення: {{time}}", + "Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}", + "Word count: {{wordCount}}": "Кількість слів: {{wordCount}}", + "Character count: {{characterCount}}": "Кількість символів: {{characterCount}}", + "New update": "Нове оновлення", + "{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}", + "Delete member": "Видалити учасника", + "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...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...", + "Table of contents": "Зміст", + "Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.", + "Share": "Поділитися", + "Public sharing": "Публічний доступ", + "Shared by": "Поділився", + "Shared at": "Поділився в", + "Inherits public sharing from": "Успадковує публічний доступ від", + "Share to web": "Поділитися в інтернеті", + "Shared to web": "Розміщено в інтернеті", + "Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку", + "Make this page publicly accessible": "Зробити цю сторінку загальнодоступною", + "Include sub-pages": "Включити підсторінки", + "Make sub-pages public too": "Зробити підсторінки також загальнодоступними", + "Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку", + "Open page": "Відкрити сторінку", + "Page": "Сторінка", + "Delete public share link": "Видалити посилання на публічний доступ", + "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 not found": "Спільний доступ не знайдено", + "Failed to share page": "Не вдалося поділитися сторінкою", + "Copy page": "Копіювати сторінки", + "Copy page to a different space.": "Скопіювати сторінку в інший простір.", + "Page copied successfully": "Сторінку успішно скопійовано" +} diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 94c3007c..ee7a6888 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处", "Share deleted successfully": "分享已成功删除", "Share not found": "未找到分享", - "Failed to share page": "页面分享失败" + "Failed to share page": "页面分享失败", + "Copy page": "复制页面", + "Copy page to a different space.": "将页面复制到不同的空间。", + "Page copied successfully": "页面复制成功" } diff --git a/apps/client/src/components/icons/confluence-icon.tsx b/apps/client/src/components/icons/confluence-icon.tsx new file mode 100644 index 00000000..499f18da --- /dev/null +++ b/apps/client/src/components/icons/confluence-icon.tsx @@ -0,0 +1,20 @@ +import { rem } from "@mantine/core"; + +interface Props { + size?: number | string; +} + +export function ConfluenceIcon({ size }: Props) { + return ( + + + + ); +} diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index 7e4d241d..2d7b3657 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css"; import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useTranslation } from "react-i18next"; +import SsoLogin from "@/ee/components/sso-login.tsx"; const formSchema = z.object({ name: z.string().trim().min(1), @@ -71,39 +72,43 @@ export function InviteSignUpForm() { {t("Join the workspace")} - -
- + - + {!invitation.enforceSso && ( + + + - - - - + + + + + +
+ )} ); diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx index fe70782d..adba98e5 100644 --- a/apps/client/src/features/auth/components/setup-workspace-form.tsx +++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx @@ -21,7 +21,7 @@ import { Link } from "react-router-dom"; import APP_ROUTE from "@/lib/app-route.ts"; const formSchema = z.object({ - workspaceName: z.string().trim().min(3).max(50), + workspaceName: z.string().trim().max(50).optional(), name: z.string().min(1).max(50), email: z .string() @@ -60,15 +60,17 @@ export function SetupWorkspaceForm() { {isCloud() && }
- + {!isCloud() && ( + + )} = (props) => { }, tippyOptions: { moveTransition: "transform 0.15s ease-out", + onCreate: (instance) => { + instance.popper.firstChild?.addEventListener("blur", (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + }); + }, onHide: () => { setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); @@ -177,8 +183,8 @@ export const EditorBubbleMenu: FC = (props) => { { - setIsLinkSelectorOpen(!isLinkSelectorOpen); + setIsOpen={(value) => { + setIsLinkSelectorOpen(value); setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); setIsColorSelectorOpen(false); diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index 02ae6edf..dfc6a5da 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -15,13 +15,13 @@ import { import { IconEdit } from "@tabler/icons-react"; import { z } from "zod"; import { useForm, zodResolver } from "@mantine/form"; -import { - getEmbedProviderById, - getEmbedUrlAndProvider, -} from "@/features/editor/components/embed/providers.ts"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import i18n from "i18next"; +import { + getEmbedProviderById, + getEmbedUrlAndProvider, +} from "@docmost/editor-ext"; const schema = z.object({ url: z @@ -32,7 +32,7 @@ const schema = z.object({ export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); - const { node, selected, updateAttributes } = props; + const { node, selected, updateAttributes, editor } = props; const { src, provider } = node.attrs; const embedUrl = useMemo(() => { @@ -50,8 +50,16 @@ export default function EmbedView(props: NodeViewProps) { }); async function onSubmit(data: { url: string }) { + if (!editor.isEditable) { + return; + } + if (provider) { const embedProvider = getEmbedProviderById(provider); + if (embedProvider.id === "iframe") { + updateAttributes({ src: data.url }); + return; + } if (embedProvider.regex.test(data.url)) { updateAttributes({ src: data.url }); } else { @@ -81,7 +89,13 @@ export default function EmbedView(props: NodeViewProps) { ) : ( - + {t("Embed {{provider}}", { - provider: getEmbedProviderById(provider).name, + provider: getEmbedProviderById(provider)?.name, })} diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts b/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts new file mode 100644 index 00000000..0fc9898f --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts @@ -0,0 +1,42 @@ +type LibraryItems = any; + +type LibraryPersistedData = { + libraryItems: LibraryItems; +}; + +export interface LibraryPersistenceAdapter { + load(metadata: { source: "load" | "save" }): + | Promise<{ libraryItems: LibraryItems } | null> + | { + libraryItems: LibraryItems; + } + | null; + + save(libraryData: LibraryPersistedData): Promise | void; +} + +const LOCAL_STORAGE_KEY = "excalidrawLibrary"; + +export const localStorageLibraryAdapter: LibraryPersistenceAdapter = { + async load() { + try { + const data = localStorage.getItem(LOCAL_STORAGE_KEY); + if (data) { + return JSON.parse(data); + } + } catch (e) { + console.error("Error downloading Excalidraw library from localStorage", e); + } + return null; + }, + async save(libraryData) { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(libraryData)); + } catch (e) { + console.error( + "Error while saving library from Excalidraw to localStorage", + e, + ); + } + }, +}; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index e0e58b28..d898e5a2 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -13,7 +13,8 @@ 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 { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types"; +import "@excalidraw/excalidraw/index.css"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import { IAttachment } from "@/lib/types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; @@ -21,6 +22,8 @@ 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 { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; const Excalidraw = lazy(() => import("@excalidraw/excalidraw").then((module) => ({ @@ -35,6 +38,10 @@ export default function ExcalidrawView(props: NodeViewProps) { const [excalidrawAPI, setExcalidrawAPI] = useState(null); + useHandleLibrary({ + excalidrawAPI, + adapter: localStorageLibraryAdapter, + }); const [excalidrawData, setExcalidrawData] = useState(null); const [opened, { open, close }] = useDisclosure(false); const computedColorScheme = useComputedColorScheme(); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index fbc3a8a4..42bed5c1 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -17,8 +17,8 @@ import { IconTable, IconTypography, IconMenu4, - IconCalendar, -} from "@tabler/icons-react"; + IconCalendar, IconAppWindow, +} from '@tabler/icons-react'; import { CommandProps, SlashMenuGroupedItemsType, @@ -357,6 +357,20 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(); }, }, + { + title: "Iframe embed", + description: "Embed any Iframe", + searchTerms: ["iframe"], + icon: IconAppWindow, + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .setEmbed({ provider: "iframe" }) + .run(); + }, + }, { title: "Airtable", description: "Embed Airtable", diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx index 5321b907..1d2985e8 100644 --- a/apps/client/src/features/editor/components/table/table-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-menu.tsx @@ -17,9 +17,9 @@ import { IconColumnRemove, IconRowInsertBottom, IconRowInsertTop, - IconRowRemove, + IconRowRemove, IconTableColumn, IconTableRow, IconTrashX, -} from "@tabler/icons-react"; +} from '@tabler/icons-react'; import { isCellSelection } from "@docmost/editor-ext"; import { useTranslation } from "react-i18next"; @@ -50,6 +50,14 @@ export const TableMenu = React.memo( return posToDOMRect(editor.view, selection.from, selection.to); }, [editor]); + const toggleHeaderColumn = useCallback(() => { + editor.chain().focus().toggleHeaderColumn().run(); + }, [editor]); + + const toggleHeaderRow = useCallback(() => { + editor.chain().focus().toggleHeaderRow().run(); + }, [editor]); + const addColumnLeft = useCallback(() => { editor.chain().focus().addColumnBefore().run(); }, [editor]); @@ -180,6 +188,30 @@ export const TableMenu = React.memo( + + + + + + + + + + + + countWords(text), + }), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; @@ -237,4 +240,4 @@ export const collabExtensions: CollabExtensions = (provider, user) => [ color: randomElement(userColors), }, }), -]; \ No newline at end of file +]; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index dfdf5aa8..cbe16adb 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -42,7 +42,11 @@ export function FullEditor({ spaceSlug={spaceSlug} editable={editable} /> - + ); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 793a9aa1..4b4e509f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -52,6 +52,7 @@ import { IPage } from "@/features/page/types/page.types.ts"; import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; import { jwtDecode } from "jwt-decode"; import { useAnchorScroll } from "./components/heading/use-anchor-scroll"; @@ -87,6 +88,8 @@ export default function PageEditor({ const { pageSlug } = useParams(); const slugId = extractPageSlugId(pageSlug); useAnchorScroll(); + const userPageEditMode = + currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); @@ -292,6 +295,17 @@ export default function PageEditor({ return () => clearTimeout(collabReadyTimeout); }, [isRemoteSynced, isLocalSynced, remoteProvider?.status]); + useEffect(() => { + // honor user default page edit mode preference + if (userPageEditMode && editor && editable && isSynced) { + if (userPageEditMode === PageEditMode.Edit) { + editor.setEditable(true); + } else if (userPageEditMode === PageEditMode.Read) { + editor.setEditable(false); + } + } + }, [userPageEditMode, editor, editable, isSynced]); + return isCollabReady ? (
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index e5ac7da6..e695867f 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -21,6 +21,8 @@ import { useTranslation } from "react-i18next"; import EmojiCommand from "@/features/editor/extensions/emoji-command.ts"; import { UpdateEvent } from "@/features/websocket/types"; import localEmitter from "@/lib/local-emitter.ts"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; export interface TitleEditorProps { pageId: string; @@ -44,6 +46,9 @@ export function TitleEditor({ const emit = useQueryEmit(); const navigate = useNavigate(); const [activePageId, setActivePageId] = useState(pageId); + const [currentUser] = useAtom(currentUserAtom); + const userPageEditMode = + currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const titleEditor = useEditor({ extensions: [ @@ -103,7 +108,7 @@ export function TitleEditor({ spaceId: page.spaceId, entity: ["pages"], id: page.id, - payload: { title: page.title, slugId: page.slugId }, + payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon }, }; if (page.title !== titleEditor.getText()) return; @@ -136,9 +141,24 @@ export function TitleEditor({ }; }, [pageId]); - function handleTitleKeyDown(event) { - if (!titleEditor || !pageEditor || event.shiftKey) return; + useEffect(() => { + // honor user default page edit mode preference + if (userPageEditMode && titleEditor && editable) { + if (userPageEditMode === PageEditMode.Edit) { + titleEditor.setEditable(true); + } else if (userPageEditMode === PageEditMode.Read) { + titleEditor.setEditable(false); + } + } + }, [userPageEditMode, titleEditor, editable]); + function handleTitleKeyDown(event: any) { + if (!titleEditor || !pageEditor || event.shiftKey) return; + + // Prevent focus shift when IME composition is active + // `keyCode === 229` is added to support Safari where `isComposing` may not be reliable + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + const { key } = event; const { $head } = titleEditor.state.selection; diff --git a/apps/client/src/features/file-task/services/file-task-service.ts b/apps/client/src/features/file-task/services/file-task-service.ts new file mode 100644 index 00000000..ffccbaae --- /dev/null +++ b/apps/client/src/features/file-task/services/file-task-service.ts @@ -0,0 +1,14 @@ +import api from "@/lib/api-client"; +import { IFileTask } from "@/features/file-task/types/file-task.types.ts"; + +export async function getFileTaskById(fileTaskId: string): Promise { + const req = await api.post("/file-tasks/info", { + fileTaskId: fileTaskId, + }); + return req.data; +} + +export async function getFileTasks(): Promise { + const req = await api.post("/file-tasks"); + return req.data; +} diff --git a/apps/client/src/features/file-task/types/file-task.types.ts b/apps/client/src/features/file-task/types/file-task.types.ts new file mode 100644 index 00000000..917e1757 --- /dev/null +++ b/apps/client/src/features/file-task/types/file-task.types.ts @@ -0,0 +1,17 @@ +export interface IFileTask { + id: string; + type: "import" | "export"; + source: string; + status: string; + fileName: string; + filePath: string; + fileSize: number; + fileExt: string; + errorMessage: string | null; + creatorId: string; + spaceId: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} \ No newline at end of file diff --git a/apps/client/src/features/page-history/services/page-history-service.ts b/apps/client/src/features/page-history/services/page-history-service.ts index 87884d0f..329ad70d 100644 --- a/apps/client/src/features/page-history/services/page-history-service.ts +++ b/apps/client/src/features/page-history/services/page-history-service.ts @@ -1,9 +1,10 @@ import api from "@/lib/api-client"; import { IPageHistory } from "@/features/page-history/types/page.types"; +import { IPagination } from "@/lib/types.ts"; export async function getPageHistoryList( pageId: string, -): Promise { +): Promise> { const req = await api.post("/pages/history", { pageId, }); diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css index cebee031..e4a0ccd6 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.module.css @@ -1,24 +1,30 @@ .breadcrumbs { - display: flex; - align-items: center; + display: flex; + align-items: center; + overflow: hidden; + flex-wrap: nowrap; + + a { + color: var(--mantine-color-default-color); + line-height: inherit; + } + + .mantine-Breadcrumbs-breadcrumb { + min-width: 1px; overflow: hidden; - flex-wrap: nowrap; - - a { - color: var(--mantine-color-default-color); - line-height: inherit; - } - - .mantine-Breadcrumbs-breadcrumb { - min-width: 1px; - overflow: hidden; - } + } } .truncatedText { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; } +.breadcrumbDiv { + overflow: hidden; + @media (max-width: $mantine-breakpoint-sm) { + overflow: visible; + } +} diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx index 9d78f38c..11507e40 100644 --- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx +++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx @@ -161,7 +161,7 @@ export default function Breadcrumb() { }; return ( -
+
{breadcrumbNodes && ( {isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 09305491..816cc502 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -33,6 +33,7 @@ import { yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import ShareModal from "@/features/share/components/share-modal.tsx"; @@ -59,6 +60,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} + {!readOnly && } + diff --git a/apps/client/src/features/page/components/header/page-header.module.css b/apps/client/src/features/page/components/header/page-header.module.css index f3e5f7a3..61f97084 100644 --- a/apps/client/src/features/page/components/header/page-header.module.css +++ b/apps/client/src/features/page/components/header/page-header.module.css @@ -1,15 +1,27 @@ .header { - height: 45px; - background-color: var(--mantine-color-body); - padding-left: var(--mantine-spacing-md); - padding-right: var(--mantine-spacing-md); - position: fixed; - z-index: 99; - top: var(--app-shell-header-offset, 0rem); - inset-inline-start: var(--app-shell-navbar-offset, 0rem); - inset-inline-end: var(--app-shell-aside-offset, 0rem); + height: 45px; + background-color: var(--mantine-color-body); + padding-left: var(--mantine-spacing-md); + padding-right: var(--mantine-spacing-md); + position: fixed; + z-index: 99; + top: var(--app-shell-header-offset, 0rem); + inset-inline-start: var(--app-shell-navbar-offset, 0rem); + inset-inline-end: var(--app-shell-aside-offset, 0rem); - @media print { - display: none; - } + @media (max-width: $mantine-breakpoint-sm) { + padding-left: var(--mantine-spacing-xs); + padding-right: var(--mantine-spacing-xs); + } + + @media print { + display: none; + } +} + +.group { + @media (max-width: $mantine-breakpoint-sm) { + gap: var(--mantine-spacing-sm); + padding-inline: 0 !important; + } } diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx index b0f380e6..12f131b8 100644 --- a/apps/client/src/features/page/components/header/page-header.tsx +++ b/apps/client/src/features/page/components/header/page-header.tsx @@ -9,10 +9,10 @@ interface Props { export default function PageHeader({ readOnly }: Props) { return (
- + - + diff --git a/apps/client/src/features/page/components/page-import-modal.tsx b/apps/client/src/features/page/components/page-import-modal.tsx index f07fd8a9..90c08bb6 100644 --- a/apps/client/src/features/page/components/page-import-modal.tsx +++ b/apps/client/src/features/page/components/page-import-modal.tsx @@ -1,18 +1,38 @@ -import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core"; import { + Modal, + Button, + SimpleGrid, + FileButton, + Group, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconBrandNotion, IconCheck, IconFileCode, + IconFileTypeZip, IconMarkdown, IconX, } from "@tabler/icons-react"; -import { importPage } from "@/features/page/services/page-service.ts"; +import { + importPage, + importZip, +} from "@/features/page/services/page-service.ts"; import { notifications } from "@mantine/notifications"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { useAtom } from "jotai"; import { buildTree } from "@/features/page/tree/utils"; import { IPage } from "@/features/page/types/page.types.ts"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; +import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; +import { formatBytes } from "@/lib"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts"; +import { queryClient } from "@/main.tsx"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; interface PageImportModalProps { spaceId: string; @@ -36,6 +56,7 @@ export default function PageImportModal({ yOffset="10vh" xOffset={0} mah={400} + keepMounted={true} > @@ -59,6 +80,133 @@ interface ImportFormatSelection { function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { const { t } = useTranslation(); const [treeData, setTreeData] = useAtom(treeDataAtom); + const [workspace] = useAtom(workspaceAtom); + const [fileTaskId, setFileTaskId] = useState(null); + const emit = useQueryEmit(); + + const canUseConfluence = isCloud() || workspace?.hasLicenseKey; + + const handleZipUpload = async (selectedFile: File, source: string) => { + if (!selectedFile) { + return; + } + + try { + onClose(); + + notifications.show({ + id: "import", + title: t("Uploading import file"), + message: t("Please don't close this tab."), + loading: true, + withCloseButton: false, + autoClose: false, + }); + + const importTask = await importZip(selectedFile, spaceId, source); + notifications.update({ + id: "import", + title: t("Importing pages"), + message: t( + "Page import is in progress. You can check back later if this takes longer.", + ), + loading: true, + withCloseButton: true, + autoClose: false, + }); + + setFileTaskId(importTask.id); + } catch (err) { + console.log("Failed to upload import file", err); + notifications.update({ + id: "import", + color: "red", + title: t("Failed to upload import file"), + message: err?.response.data.message, + icon: , + loading: false, + withCloseButton: true, + autoClose: false, + }); + } + }; + + useEffect(() => { + if (!fileTaskId) return; + + const intervalId = setInterval(async () => { + try { + const fileTask = await getFileTaskById(fileTaskId); + const status = fileTask.status; + + if (status === "success") { + notifications.update({ + id: "import", + color: "teal", + title: t("Import complete"), + message: t("Your pages were successfully imported."), + icon: , + loading: false, + withCloseButton: true, + autoClose: false, + }); + clearInterval(intervalId); + setFileTaskId(null); + + await queryClient.refetchQueries({ + queryKey: ["root-sidebar-pages", fileTask.spaceId], + }); + + setTimeout(() => { + emit({ + operation: "refetchRootTreeNodeEvent", + spaceId: spaceId, + }); + }, 50); + } + + if (status === "failed") { + notifications.update({ + id: "import", + color: "red", + title: t("Page import failed"), + message: t( + "Something went wrong while importing pages: {{reason}}.", + { + reason: fileTask.errorMessage, + }, + ), + icon: , + loading: false, + withCloseButton: true, + autoClose: false, + }); + clearInterval(intervalId); + setFileTaskId(null); + console.error(fileTask.errorMessage); + } + } catch (err) { + notifications.update({ + id: "import", + color: "red", + title: t("Import failed"), + message: t( + "Something went wrong while importing pages: {{reason}}.", + { + reason: err.response?.data.message, + }, + ), + icon: , + loading: false, + withCloseButton: true, + autoClose: false, + }); + clearInterval(intervalId); + setFileTaskId(null); + console.error("Failed to fetch import status", err); + } + }, 3000); + }, [fileTaskId]); const handleFileUpload = async (selectedFiles: File[]) => { if (!selectedFiles) { @@ -120,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { } }; + // @ts-ignore return ( <> @@ -148,7 +297,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { )} + + handleZipUpload(file, "notion")} + accept="application/zip" + > + {(props) => ( + + )} + + handleZipUpload(file, "confluence")} + accept="application/zip" + > + {(props) => ( + + + + )} + + + +
+ + Import zip file + + + {t( + `Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`, + { + sizeLimit: formatBytes(getFileImportSizeLimit()), + }, + )} + + handleZipUpload(file, "generic")} + accept="application/zip" + > + {(props) => ( + + + + )} + +
+
); } diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 6842cc9e..6a460c68 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -1,5 +1,8 @@ import { + InfiniteData, + QueryKey, useInfiniteQuery, + UseInfiniteQueryResult, useMutation, useQuery, useQueryClient, @@ -14,6 +17,7 @@ import { movePage, getPageBreadcrumbs, getRecentChanges, + getAllSidebarPages, } from "@/features/page/services/page-service"; import { IMovePage, @@ -56,7 +60,9 @@ export function useCreatePageMutation() { const { t } = useTranslation(); return useMutation>({ mutationFn: (data) => createPage(data), - onSuccess: (data) => {}, + onSuccess: (data) => { + invalidateOnCreatePage(data); + }, onError: (error) => { notifications.show({ message: t("Failed to create page"), color: "red" }); }, @@ -80,6 +86,8 @@ export function updatePageData(data: IPage) { if (pageById) { queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); } + + invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon); } export function useUpdateTitlePageMutation() { @@ -93,6 +101,8 @@ export function useUpdatePageMutation() { mutationFn: (data) => updatePage(data), onSuccess: (data) => { updatePage(data); + + invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon); }, }); } @@ -101,8 +111,9 @@ export function useDeletePageMutation() { const { t } = useTranslation(); return useMutation({ mutationFn: (pageId: string) => deletePage(pageId), - onSuccess: () => { + onSuccess: (data, pageId) => { notifications.show({ message: t("Page deleted successfully") }); + invalidateOnDeletePage(pageId); }, onError: (error) => { notifications.show({ message: t("Failed to delete page"), color: "red" }); @@ -113,15 +124,21 @@ export function useDeletePageMutation() { export function useMovePageMutation() { return useMutation({ mutationFn: (data) => movePage(data), + onSuccess: () => { + invalidateOnMovePage(); + }, }); } -export function useGetSidebarPagesQuery( - data: SidebarPagesParams, -): UseQueryResult, Error> { - return useQuery({ +export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult, unknown>> { + return useInfiniteQuery({ queryKey: ["sidebar-pages", data], - queryFn: () => getSidebarPages(data), + queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), + initialPageParam: 1, + getPreviousPageParam: (firstPage) => + firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, }); } @@ -149,14 +166,16 @@ export function usePageBreadcrumbsQuery( }); } -export async function fetchAncestorChildren(params: SidebarPagesParams) { +export async function fetchAllAncestorChildren(params: SidebarPagesParams) { // not using a hook here, so we can call it inside a useEffect hook const response = await queryClient.fetchQuery({ queryKey: ["sidebar-pages", params], - queryFn: () => getSidebarPages(params), + queryFn: () => getAllSidebarPages(params), staleTime: 30 * 60 * 1000, }); - return buildTree(response.items); + + const allItems = response.pages.flatMap((page) => page.items); + return buildTree(allItems); } export function useRecentChangesQuery( @@ -168,3 +187,157 @@ export function useRecentChangesQuery( refetchOnMount: true, }); } + +export function invalidateOnCreatePage(data: Partial) { + const newPage: Partial = { + creatorId: data.creatorId, + hasChildren: data.hasChildren, + icon: data.icon, + id: data.id, + parentPageId: data.parentPageId, + position: data.position, + slugId: data.slugId, + spaceId: data.spaceId, + title: data.title, + }; + + let queryKey: QueryKey = null; + if (data.parentPageId===null) { + queryKey = ['root-sidebar-pages', data.spaceId]; + }else{ + queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}] + } + + //update all sidebar pages + queryClient.setQueryData>>>(queryKey, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page,index) => { + if (index === old.pages.length - 1) { + return { + ...page, + items: [...page.items, newPage], + }; + } + return page; + }), + }; + }); + + //update sidebar haschildren + if (data.parentPageId!==null){ + //update sub sidebar pages haschildern + const subSideBarMatches = queryClient.getQueriesData({ + queryKey: ['sidebar-pages'], + exact: false, + }); + + subSideBarMatches.forEach(([key, d]) => { + queryClient.setQueryData>>(key, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((sidebarPage: IPage) => + sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage + ) + })), + }; + }); + }); + + //update root sidebar pages haschildern + const rootSideBarMatches = queryClient.getQueriesData({ + queryKey: ['root-sidebar-pages', data.spaceId], + exact: false, + }); + + rootSideBarMatches.forEach(([key, d]) => { + queryClient.setQueryData>>(key, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((sidebarPage: IPage) => + sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage + ) + })), + }; + }); + }); + } + + //update recent changes + queryClient.invalidateQueries({ + queryKey: ["recent-changes", data.spaceId], + }); +} + +export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) { + let queryKey: QueryKey = null; + if(parentPageId===null){ + queryKey = ['root-sidebar-pages', spaceId]; + }else{ + queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}] + } + //update all sidebar pages + queryClient.setQueryData>>(queryKey, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((sidebarPage: IPage) => + sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage + ) + })), + }; + }); + + //update recent changes + queryClient.invalidateQueries({ + queryKey: ["recent-changes", spaceId], + }); +} + +export function invalidateOnMovePage() { + //for move invalidate all sidebars for now (how to do???) + //invalidate all root sidebar pages + queryClient.invalidateQueries({ + queryKey: ["root-sidebar-pages"], + }); + //invalidate all sub sidebar pages + queryClient.invalidateQueries({ + queryKey: ['sidebar-pages'], + }); + // --- +} + +export function invalidateOnDeletePage(pageId: string) { + //update all sidebar pages + const allSideBarMatches = queryClient.getQueriesData({ + predicate: (query) => + query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages', + }); + + allSideBarMatches.forEach(([key, d]) => { + queryClient.setQueryData>>(key, (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId), + })), + }; + }); + }); + + //update recent changes + queryClient.invalidateQueries({ + queryKey: ["recent-changes"], + }); +} \ No newline at end of file diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 5e69a34a..a8e3d256 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -7,9 +7,11 @@ import { IPage, IPageInput, SidebarPagesParams, -} from "@/features/page/types/page.types"; +} from '@/features/page/types/page.types'; import { IAttachment, IPagination } from "@/lib/types.ts"; import { saveAs } from "file-saver"; +import { InfiniteData } from "@tanstack/react-query"; +import { IFileTask } from '@/features/file-task/types/file-task.types.ts'; export async function createPage(data: Partial): Promise { const req = await api.post("/pages/create", data); @@ -52,6 +54,32 @@ export async function getSidebarPages( return req.data; } +export async function getAllSidebarPages( + params: SidebarPagesParams, +): Promise, unknown>> { + let page = 1; + let hasNextPage = false; + const pages: IPagination[] = []; + const pageParams: number[] = []; + + do { + const req = await api.post("/pages/sidebar-pages", { ...params, page: page }); + + const data: IPagination = req.data; + pages.push(data); + pageParams.push(page); + + hasNextPage = data.meta.hasNextPage; + + page += 1; + } while (hasNextPage); + + return { + pageParams, + pages, + }; +} + export async function getPageBreadcrumbs( pageId: string, ): Promise> { @@ -92,6 +120,25 @@ export async function importPage(file: File, spaceId: string) { return req.data; } +export async function importZip( + file: File, + spaceId: string, + source?: string, +): Promise { + const formData = new FormData(); + formData.append("spaceId", spaceId); + formData.append("source", source); + formData.append("file", file); + + const req = await api.post("/pages/import-zip", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + return req.data; +} + export async function uploadFile( file: File, pageId: string, diff --git a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts index e3910cb8..7d0ec503 100644 --- a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts +++ b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts @@ -1,4 +1,19 @@ import { atom } from "jotai"; import { SpaceTreeNode } from "@/features/page/tree/types"; +import { appendNodeChildren } from "../utils"; export const treeDataAtom = atom([]); + +// Atom +export const appendNodeChildrenAtom = atom( + null, + ( + get, + set, + { parentId, children }: { parentId: string; children: SpaceTreeNode[] } + ) => { + const currentTree = get(treeDataAtom); + const updatedTree = appendNodeChildren(currentTree, parentId, children); + set(treeDataAtom, updatedTree); + } +); diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 1df62678..db818518 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -2,7 +2,7 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { - fetchAncestorChildren, + fetchAllAncestorChildren, useGetRootSidebarPagesQuery, usePageQuery, useUpdatePageMutation, @@ -24,7 +24,10 @@ import { IconPointFilled, IconTrash, } from "@tabler/icons-react"; -import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { + appendNodeChildrenAtom, + treeDataAtom, +} from "@/features/page/tree/atoms/tree-data-atom.ts"; import clsx from "clsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; @@ -32,6 +35,7 @@ import { appendNodeChildren, buildTree, buildTreeWithChildren, + mergeRootTrees, updateTreeNodeIcon, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; @@ -104,17 +108,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const allItems = pagesData.pages.flatMap((page) => page.items); const treeData = buildTree(allItems); - if (data.length < 1 || data?.[0].spaceId !== spaceId) { - //Thoughts - // don't reset if there is data in state - // we only expect to call this once on initial load - // even if we decide to refetch, it should only update - // and append root pages instead of resetting the entire tree - // which looses async loaded children too - setData(treeData); - setIsDataLoaded(true); - setOpenTreeNodes({}); - } + setData((prev) => { + // fresh space; full reset + if (prev.length === 0 || prev[0]?.spaceId !== spaceId) { + setIsDataLoaded(true); + setOpenTreeNodes({}); + return treeData; + } + + // same space; append only missing roots + return mergeRootTrees(prev, treeData); + }); } }, [pagesData, hasNextPage]); @@ -140,7 +144,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { if (ancestor.id === currentPage.id) { return; } - const children = await fetchAncestorChildren({ + const children = await fetchAllAncestorChildren({ pageId: ancestor.id, spaceId: ancestor.spaceId, }); @@ -237,6 +241,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const { t } = useTranslation(); const updatePageMutation = useUpdatePageMutation(); const [treeData, setTreeData] = useAtom(treeDataAtom); + const [, appendChildren] = useAtom(appendNodeChildrenAtom); const emit = useQueryEmit(); const { spaceSlug } = useParams(); const timerRef = useRef(null); @@ -262,9 +267,10 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; - if (node.data.children && node.data.children.length > 0) { - return; - } + // in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket + // if (node.data.children && node.data.children.length > 0) { + // return; + // } try { const params: SidebarPagesParams = { @@ -272,21 +278,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { spaceId: node.data.spaceId, }; - const newChildren = await queryClient.fetchQuery({ - queryKey: ["sidebar-pages", params], - queryFn: () => getSidebarPages(params), - staleTime: 10 * 60 * 1000, + const childrenTree = await fetchAllAncestorChildren(params); + + appendChildren({ + parentId: node.data.id, + children: childrenTree, }); - - const childrenTree = buildTree(newChildren.items); - - const updatedTreeData = appendNodeChildren( - treeData, - node.data.id, - childrenTree, - ); - - setTreeData(updatedTreeData); } catch (error) { console.error("Failed to fetch children:", error); } @@ -304,17 +301,19 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const handleEmojiSelect = (emoji: { native: string }) => { handleUpdateNodeIcon(node.id, emoji.native); - updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native }); - - setTimeout(() => { - emit({ - operation: "updateOne", - spaceId: node.data.spaceId, - entity: ["pages"], - id: node.id, - payload: { icon: emoji.native }, + updatePageMutation + .mutateAsync({ pageId: node.id, icon: emoji.native }) + .then((data) => { + setTimeout(() => { + emit({ + operation: "updateOne", + spaceId: node.data.spaceId, + entity: ["pages"], + id: node.id, + payload: { icon: emoji.native, parentPageId: data.parentPageId }, + }); + }, 50); }); - }, 50); }; const handleRemoveEmoji = () => { @@ -576,6 +575,12 @@ interface PageArrowProps { } function PageArrow({ node, onExpandTree }: PageArrowProps) { + useEffect(() => { + if (node.isOpen) { + onExpandTree(); + } + }, []); + return ( (spaceId: string) { return data; }; - const onMove: MoveHandler = (args: { + const onMove: MoveHandler = async (args: { dragIds: string[]; dragNodes: NodeApi[]; parentId: string | null; @@ -176,7 +176,7 @@ export function useTreeMutation(spaceId: string) { }; try { - movePageMutation.mutateAsync(payload); + await movePageMutation.mutateAsync(payload); setTimeout(() => { emit({ @@ -206,6 +206,23 @@ export function useTreeMutation(spaceId: string) { } }; + const isPageInNode = ( + node: { data: SpaceTreeNode; children?: any[] }, + pageSlug: string + ): boolean => { + if (node.data.slugId === pageSlug) { + return true; + } + for (const item of node.children) { + if (item.data.slugId === pageSlug) { + return true; + } else { + return isPageInNode(item, pageSlug); + } + } + return false; + }; + const onDelete: DeleteHandler = async (args: { ids: string[] }) => { try { await deletePageMutation.mutateAsync(args.ids[0]); @@ -218,8 +235,7 @@ export function useTreeMutation(spaceId: string) { tree.drop({ id: args.ids[0] }); setData(tree.data); - // navigate only if the current url is same as the deleted page - if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) { + if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) { navigate(getSpaceUrl(spaceSlug)); } diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 7ae84e38..8ec1b884 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -121,7 +121,6 @@ export const deleteTreeNode = ( .filter((node) => node !== null); }; - export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] { const nodeMap = {}; let result: SpaceTreeNode[] = []; @@ -164,16 +163,55 @@ export function appendNodeChildren( nodeId: string, children: SpaceTreeNode[], ) { - return treeItems.map((nodeItem) => { - if (nodeItem.id === nodeId) { - return { ...nodeItem, children }; - } - if (nodeItem.children) { + // Preserve deeper children if they exist and remove node if deleted + return treeItems.map((node) => { + if (node.id === nodeId) { + const newIds = new Set(children.map((c) => c.id)); + + const existingMap = new Map( + (node.children ?? []) + .filter((c) => newIds.has(c.id)) + .map((c) => [c.id, c]), + ); + + const merged = children.map((newChild) => { + const existing = existingMap.get(newChild.id); + return existing && existing.children + ? { ...newChild, children: existing.children } + : newChild; + }); + return { - ...nodeItem, - children: appendNodeChildren(nodeItem.children, nodeId, children), + ...node, + children: merged, }; } - return nodeItem; + + if (node.children) { + return { + ...node, + children: appendNodeChildren(node.children, nodeId, children), + }; + } + + return node; }); } + +/** + * Merge root nodes; keep existing ones intact, append new ones, + */ +export function mergeRootTrees( + prevRoots: SpaceTreeNode[], + incomingRoots: SpaceTreeNode[], +): SpaceTreeNode[] { + const seen = new Set(prevRoots.map((r) => r.id)); + + // add new roots that were not present before + const merged = [...prevRoots]; + incomingRoots.forEach((node) => { + if (!seen.has(node.id)) merged.push(node); + }); + + return sortPositionKeys(merged); +} diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 9156c98c..19dc18fd 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -65,6 +65,7 @@ export interface IPageInput { icon: string; coverPhoto: string; position: string; + isLocked: boolean; } export interface IExportPageParams { diff --git a/apps/client/src/features/user/components/account-language.tsx b/apps/client/src/features/user/components/account-language.tsx index f0146390..b0093ed9 100644 --- a/apps/client/src/features/user/components/account-language.tsx +++ b/apps/client/src/features/user/components/account-language.tsx @@ -42,14 +42,15 @@ function LanguageSwitcher() { label={t("Select language")} data={[ { value: "en-US", label: "English (US)" }, - { value: "de-DE", label: "Deutsch (German)" }, - { value: "nl-NL", label: "Dutch (Netherlands)" }, - { value: "fr-FR", label: "Français (French)" }, { value: "es-ES", label: "Español (Spanish)" }, + { value: "de-DE", label: "Deutsch (German)" }, + { value: "fr-FR", label: "Français (French)" }, + { value: "nl-NL", label: "Dutch (Netherlands)" }, { value: "pt-BR", label: "Português (Brasil)" }, { value: "it-IT", label: "Italiano (Italian)" }, { value: "ja-JP", label: "日本語 (Japanese)" }, { value: "ko-KR", label: "한국어 (Korean)" }, + { value: "uk-UA", label: "Українська (Ukrainian)" }, { value: "ru-RU", label: "Русский (Russian)" }, { value: "zh-CN", label: "中文 (简体)" }, ]} diff --git a/apps/client/src/features/user/components/account-name-form.tsx b/apps/client/src/features/user/components/account-name-form.tsx index c3de24c7..0b829564 100644 --- a/apps/client/src/features/user/components/account-name-form.tsx +++ b/apps/client/src/features/user/components/account-name-form.tsx @@ -11,7 +11,7 @@ import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; const formSchema = z.object({ - name: z.string().min(2).max(40), + name: z.string().min(1).max(40), }); type FormValues = z.infer; diff --git a/apps/client/src/features/user/components/page-state-pref.tsx b/apps/client/src/features/user/components/page-state-pref.tsx new file mode 100644 index 00000000..12ba2d6f --- /dev/null +++ b/apps/client/src/features/user/components/page-state-pref.tsx @@ -0,0 +1,65 @@ +import { Group, Text, MantineSize, SegmentedControl } from "@mantine/core"; +import { useAtom } from "jotai"; +import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { updateUser } from "@/features/user/services/user-service.ts"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PageEditMode } from "@/features/user/types/user.types.ts"; + +export default function PageStatePref() { + const { t } = useTranslation(); + + return ( + +
+ {t("Default page edit mode")} + + {t("Choose your preferred page edit mode. Avoid accidental edits.")} + +
+ + +
+ ); +} + +interface PageStateSegmentedControlProps { + size?: MantineSize; +} + +export function PageStateSegmentedControl({ + size, +}: PageStateSegmentedControlProps) { + const { t } = useTranslation(); + const [user, setUser] = useAtom(userAtom); + const pageEditMode = + user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; + const [value, setValue] = useState(pageEditMode); + + const handleChange = useCallback( + async (value: string) => { + const updatedUser = await updateUser({ pageEditMode: value }); + setValue(value); + setUser(updatedUser); + }, + [user, setUser], + ); + + useEffect(() => { + if (pageEditMode !== value) { + setValue(pageEditMode); + } + }, [pageEditMode, value]); + + return ( + + ); +} diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts index 5439580f..95060358 100644 --- a/apps/client/src/features/user/types/user.types.ts +++ b/apps/client/src/features/user/types/user.types.ts @@ -19,6 +19,7 @@ export interface IUser { deactivatedAt: Date; deletedAt: Date; fullPageWidth: boolean; // used for update + pageEditMode: string; // used for update } export interface ICurrentUser { @@ -29,5 +30,11 @@ export interface ICurrentUser { export interface IUserSettings { preferences: { fullPageWidth: boolean; + pageEditMode: string; }; -} \ No newline at end of file +} + +export enum PageEditMode { + Read = "read", + Edit = "edit", +} diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 48e7d819..25b57df9 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -1,4 +1,5 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts"; +import { IPage } from "@/features/page/types/page.types"; export type InvalidateEvent = { operation: "invalidate"; @@ -17,7 +18,7 @@ export type UpdateEvent = { spaceId: string; entity: Array; id: string; - payload: Partial; + payload: Partial; }; export type DeleteEvent = { @@ -25,7 +26,7 @@ export type DeleteEvent = { spaceId: string; entity: Array; id: string; - payload?: Partial; + payload?: Partial; }; export type AddTreeNodeEvent = { @@ -46,15 +47,28 @@ export type MoveTreeNodeEvent = { parentId: string; index: number; position: string; - } + }; }; export type DeleteTreeNodeEvent = { operation: "deleteTreeNode"; spaceId: string; payload: { - node: SpaceTreeNode - } + node: SpaceTreeNode; + }; }; -export type WebSocketEvent = InvalidateEvent | InvalidateCommentsEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent; +export type RefetchRootTreeNodeEvent = { + operation: "refetchRootTreeNodeEvent"; + spaceId: string; +}; + +export type WebSocketEvent = + | InvalidateEvent + | InvalidateCommentsEvent + | UpdateEvent + | DeleteEvent + | AddTreeNodeEvent + | MoveTreeNodeEvent + | DeleteTreeNodeEvent + | RefetchRootTreeNodeEvent; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index c9e53aa6..29a815be 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -1,9 +1,18 @@ import React from "react"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { useAtom } from "jotai"; -import { useQueryClient } from "@tanstack/react-query"; +import { InfiniteData, useQueryClient } from "@tanstack/react-query"; import { WebSocketEvent } from "@/features/websocket/types"; +import { IPage } from "../page/types/page.types"; +import { IPagination } from "@/lib/types"; +import { + invalidateOnCreatePage, + invalidateOnDeletePage, + invalidateOnMovePage, + invalidateOnUpdatePage, +} from "../page/queries/page-query"; import { RQ_KEY } from "../comment/queries/comment-query"; +import { queryClient } from "@/main.tsx"; export const useQuerySubscription = () => { const queryClient = useQueryClient(); @@ -27,6 +36,15 @@ export const useQuerySubscription = () => { queryKey: RQ_KEY(data.pageId), }); break; + case "addTreeNode": + invalidateOnCreatePage(data.payload.data); + break; + case "moveTreeNode": + invalidateOnMovePage(); + break; + case "deleteTreeNode": + invalidateOnDeletePage(data.payload.node.id); + break; case "updateOne": entity = data.entity[0]; if (entity === "pages") { @@ -37,13 +55,23 @@ export const useQuerySubscription = () => { } // only update if data was already in cache - if(queryClient.getQueryData([...data.entity, queryKeyId])){ + if (queryClient.getQueryData([...data.entity, queryKeyId])) { queryClient.setQueryData([...data.entity, queryKeyId], { ...queryClient.getQueryData([...data.entity, queryKeyId]), ...data.payload, }); } + if (entity === "pages") { + invalidateOnUpdatePage( + data.spaceId, + data.payload.parentPageId, + data.id, + data.payload.title, + data.payload.icon, + ); + } + /* queryClient.setQueriesData( { queryKey: [data.entity, data.id] }, @@ -57,6 +85,17 @@ export const useQuerySubscription = () => { ); */ break; + case "refetchRootTreeNodeEvent": { + const spaceId = data.spaceId; + queryClient.refetchQueries({ + queryKey: ["root-sidebar-pages", spaceId], + }); + + queryClient.invalidateQueries({ + queryKey: ["recent-changes", spaceId], + }); + break; + } } }); }, [queryClient, socket]); diff --git a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx index d98d32ef..f8cd5d2d 100644 --- a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx +++ b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx @@ -11,7 +11,7 @@ import useUserRole from "@/hooks/use-user-role.tsx"; import { useTranslation } from "react-i18next"; const formSchema = z.object({ - name: z.string().min(4), + name: z.string().min(1), }); type FormValues = z.infer; diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts index 0add1d0a..6bf35aec 100644 --- a/apps/client/src/features/workspace/queries/workspace-query.ts +++ b/apps/client/src/features/workspace/queries/workspace-query.ts @@ -173,7 +173,7 @@ export function useRevokeInvitationMutation() { export function useGetInvitationQuery( invitationId: string, -): UseQueryResult { +): UseQueryResult { return useQuery({ queryKey: ["invitations", invitationId], queryFn: () => getInvitationById({ invitationId }), diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 4112ee5c..730106ce 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -35,6 +35,7 @@ export interface IInvitation { workspaceId: string; invitedById: string; createdAt: Date; + enforceSso: boolean; } export interface IInvitationLink { diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index 2f621b91..717bf9ff 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -70,6 +70,11 @@ export function getFileUploadSizeLimit() { return bytes(limit); } +export function getFileImportSizeLimit() { + const limit = getConfigValue("FILE_IMPORT_SIZE_LIMIT", "200mb"); + return bytes(limit); +} + export function getDrawioUrl() { return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net"); } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index 43d0fe59..65a4f64f 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -12,6 +12,11 @@ import { SpaceCaslSubject, } from "@/features/space/permissions/permissions.type.ts"; import { useTranslation } from "react-i18next"; +import React from "react"; + +const MemoizedFullEditor = React.memo(FullEditor); +const MemoizedPageHeader = React.memo(PageHeader); +const MemoizedHistoryModal = React.memo(HistoryModal); export default function Page() { const { t } = useTranslation(); @@ -49,14 +54,14 @@ export default function Page() { {`${page?.icon || ""} ${page?.title || t("untitled")}`} - - - +
) ); diff --git a/apps/client/src/pages/settings/account/account-preferences.tsx b/apps/client/src/pages/settings/account/account-preferences.tsx index 26daa488..f082ea1b 100644 --- a/apps/client/src/pages/settings/account/account-preferences.tsx +++ b/apps/client/src/pages/settings/account/account-preferences.tsx @@ -2,6 +2,7 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import AccountLanguage from "@/features/user/components/account-language.tsx"; import AccountTheme from "@/features/user/components/account-theme.tsx"; import PageWidthPref from "@/features/user/components/page-width-pref.tsx"; +import PageEditPref from "@/features/user/components/page-state-pref"; import { getAppName } from "@/lib/config.ts"; import { Divider } from "@mantine/core"; import { Helmet } from "react-helmet-async"; @@ -28,6 +29,10 @@ export default function AccountPreferences() { + + + + ); } diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index a6efc4bc..cc8a01fd 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig(({ mode }) => { const { APP_URL, FILE_UPLOAD_SIZE_LIMIT, + FILE_IMPORT_SIZE_LIMIT, DRAWIO_URL, CLOUD, SUBDOMAIN_HOST, @@ -20,6 +21,7 @@ export default defineConfig(({ mode }) => { "process.env": { APP_URL, FILE_UPLOAD_SIZE_LIMIT, + FILE_IMPORT_SIZE_LIMIT, DRAWIO_URL, CLOUD, SUBDOMAIN_HOST, diff --git a/apps/server/package.json b/apps/server/package.json index 5082d129..63fa0be1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.20.4", + "version": "0.21.0", "description": "", "author": "", "private": true, @@ -31,56 +31,60 @@ }, "dependencies": { "@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.1.1", + "@fastify/static": "^8.2.0", "@nestjs/bullmq": "^11.0.2", - "@nestjs/common": "^11.0.20", + "@nestjs/common": "^11.1.3", "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.20", - "@nestjs/event-emitter": "^3.0.0", + "@nestjs/core": "^11.1.3", + "@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.0.20", - "@nestjs/platform-socket.io": "^11.0.20", - "@nestjs/schedule": "^5.0.1", + "@nestjs/platform-fastify": "^11.1.3", + "@nestjs/platform-socket.io": "^11.1.3", + "@nestjs/schedule": "^6.0.0", "@nestjs/terminus": "^11.0.0", - "@nestjs/websockets": "^11.0.20", + "@nestjs/websockets": "^11.1.3", "@node-saml/passport-saml": "^5.0.1", "@react-email/components": "0.0.28", "@react-email/render": "1.0.2", "@socket.io/redis-adapter": "^8.3.0", "bcrypt": "^5.1.1", - "bullmq": "^5.41.3", - "cache-manager": "^6.4.0", + "bullmq": "^5.53.2", + "cache-manager": "^6.4.3", + "cheerio": "^1.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie": "^1.0.2", "fs-extra": "^11.3.0", "happy-dom": "^15.11.6", "jsonwebtoken": "^9.0.2", - "kysely": "^0.27.5", + "kysely": "^0.28.2", "kysely-migration-cli": "^0.4.2", "mime-types": "^2.1.35", "nanoid": "3.3.11", - "nestjs-kysely": "^1.1.0", - "nodemailer": "^6.10.0", + "nestjs-kysely": "^1.2.0", + "nodemailer": "^7.0.3", "openid-client": "^5.7.1", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "pg": "^8.13.3", + "pg": "^8.16.0", "pg-tsquery": "^8.4.2", "postmark": "^4.0.5", "react": "^18.3.1", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", + "rxjs": "^7.8.2", "sanitize-filename-ts": "^1.0.2", "socket.io": "^4.8.1", "stripe": "^17.5.0", - "ws": "^8.18.0" + "tmp-promise": "^3.0.3", + "ws": "^8.18.2", + "yauzl": "^3.2.0" }, "devDependencies": { "@eslint/js": "^9.20.0", @@ -99,6 +103,7 @@ "@types/pg": "^8.11.11", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.14", + "@types/yauzl": "^2.10.3", "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", "globals": "^15.15.0", diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 3c206e1a..88284fd2 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -130,7 +130,7 @@ export class PersistenceExtension implements Extension { ); this.contributors.delete(documentName); } catch (err) { - this.logger.debug('Contributors error:' + err?.['message']); + //this.logger.debug('Contributors error:' + err?.['message']); } await this.pageRepo.updatePage( diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index e2a4d5eb..87448bc7 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; +import { sanitize } from 'sanitize-filename-ts'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); @@ -15,6 +16,12 @@ export async function comparePasswordHash( return bcrypt.compare(plainPassword, passwordHash); } +export function generateRandomSuffixNumbers(length: number) { + return Math.random() + .toFixed(length) + .substring(2, 2 + length); +} + export type RedisConfig = { host: string; port: number; @@ -62,3 +69,8 @@ export function extractDateFromUuid7(uuid7: string) { return new Date(timestamp); } + +export function sanitizeFileName(fileName: string): string { + const sanitizedFilename = sanitize(fileName).replace(/ /g, '_'); + return sanitizedFilename.slice(0, 255); +} diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 20cc6ed5..fb98ed7f 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -1,11 +1,9 @@ import { - BadRequestException, Body, Controller, HttpCode, HttpStatus, Post, - Req, Res, UseGuards, } from '@nestjs/common'; @@ -23,7 +21,6 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { PasswordResetDto } from './dto/password-reset.dto'; import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; import { FastifyReply } from 'fastify'; -import { addDays } from 'date-fns'; import { validateSsoEnforcement } from './auth.util'; @Controller('auth') @@ -125,7 +122,7 @@ export class AuthController { res.setCookie('authToken', token, { httpOnly: true, path: '/', - expires: addDays(new Date(), 30), + expires: this.environmentService.getCookieExpiresIn(), secure: this.environmentService.isHttps(), }); } diff --git a/apps/server/src/core/auth/auth.util.ts b/apps/server/src/core/auth/auth.util.ts index ee781603..cdd46e9b 100644 --- a/apps/server/src/core/auth/auth.util.ts +++ b/apps/server/src/core/auth/auth.util.ts @@ -6,3 +6,16 @@ export function validateSsoEnforcement(workspace: Workspace) { throw new BadRequestException('This workspace has enforced SSO login.'); } } + +export function validateAllowedEmail(userEmail: string, workspace: Workspace) { + const emailParts = userEmail.split('@'); + const emailDomain = emailParts[1].toLowerCase(); + if ( + workspace.emailDomains?.length > 0 && + !workspace.emailDomains.includes(emailDomain) + ) { + throw new BadRequestException( + `The email domain "${emailDomain}" is not approved for this workspace.`, + ); + } +} diff --git a/apps/server/src/core/auth/dto/create-admin-user.dto.ts b/apps/server/src/core/auth/dto/create-admin-user.dto.ts index fb6b4cb9..bdea75fe 100644 --- a/apps/server/src/core/auth/dto/create-admin-user.dto.ts +++ b/apps/server/src/core/auth/dto/create-admin-user.dto.ts @@ -1,6 +1,12 @@ -import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; +import { + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; import { CreateUserDto } from './create-user.dto'; -import {Transform, TransformFnParams} from "class-transformer"; +import { Transform, TransformFnParams } from 'class-transformer'; export class CreateAdminUserDto extends CreateUserDto { @IsNotEmpty() @@ -9,10 +15,17 @@ export class CreateAdminUserDto extends CreateUserDto { @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; - @IsNotEmpty() - @MinLength(3) + @IsOptional() + @MinLength(1) @MaxLength(50) @IsString() @Transform(({ value }: TransformFnParams) => value?.trim()) workspaceName: string; + + @IsOptional() + @MinLength(4) + @MaxLength(50) + @IsString() + @Transform(({ value }: TransformFnParams) => value?.trim()) + hostname?: string; } diff --git a/apps/server/src/core/auth/services/signup.service.ts b/apps/server/src/core/auth/services/signup.service.ts index a6921c89..bf089478 100644 --- a/apps/server/src/core/auth/services/signup.service.ts +++ b/apps/server/src/core/auth/services/signup.service.ts @@ -92,7 +92,8 @@ export class SignupService { // create workspace with full setup const workspaceData: CreateWorkspaceDto = { - name: createAdminUserDto.workspaceName, + name: createAdminUserDto.workspaceName || 'My workspace', + hostname: createAdminUserDto.hostname, }; workspace = await this.workspaceService.create( diff --git a/apps/server/src/core/user/dto/update-user.dto.ts b/apps/server/src/core/user/dto/update-user.dto.ts index cdf085bb..6b55a3db 100644 --- a/apps/server/src/core/user/dto/update-user.dto.ts +++ b/apps/server/src/core/user/dto/update-user.dto.ts @@ -1,5 +1,5 @@ import { OmitType, PartialType } from '@nestjs/mapped-types'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; import { CreateUserDto } from '../../auth/dto/create-user.dto'; export class UpdateUserDto extends PartialType( @@ -13,6 +13,11 @@ export class UpdateUserDto extends PartialType( @IsBoolean() fullPageWidth: boolean; + @IsOptional() + @IsString() + @IsIn(['read', 'edit']) + pageEditMode: string; + @IsOptional() @IsString() locale: string; diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts index 434f4cac..d7c59320 100644 --- a/apps/server/src/core/user/user.service.ts +++ b/apps/server/src/core/user/user.service.ts @@ -34,6 +34,14 @@ export class UserService { ); } + if (typeof updateUserDto.pageEditMode !== 'undefined') { + return this.userRepo.updatePreference( + userId, + 'pageEditMode', + updateUserDto.pageEditMode.toLowerCase(), + ); + } + if (updateUserDto.name) { user.name = updateUserDto.name; } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 218c79a3..47a78480 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -29,9 +29,7 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact import { WorkspaceCaslAction, WorkspaceCaslSubject, -} from '../../casl/interfaces/workspace-ability.type'; -import { addDays } from 'date-fns'; -import { FastifyReply } from 'fastify'; +} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { CheckHostnameDto } from '../dto/check-hostname.dto'; import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto'; @@ -180,10 +178,13 @@ export class WorkspaceController { @Public() @HttpCode(HttpStatus.OK) @Post('invites/info') - async getInvitationById(@Body() dto: InvitationIdDto, @Req() req: any) { + async getInvitationById( + @Body() dto: InvitationIdDto, + @AuthWorkspace() workspace: Workspace, + ) { return this.workspaceInvitationService.getInvitationById( dto.invitationId, - req.raw.workspaceId, + workspace, ); } @@ -253,18 +254,18 @@ export class WorkspaceController { @Post('invites/accept') async acceptInvite( @Body() acceptInviteDto: AcceptInviteDto, - @Req() req: any, + @AuthWorkspace() workspace: Workspace, @Res({ passthrough: true }) res: FastifyReply, ) { const authToken = await this.workspaceInvitationService.acceptInvitation( acceptInviteDto, - req.raw.workspaceId, + workspace, ); res.setCookie('authToken', authToken, { httpOnly: true, path: '/', - expires: addDays(new Date(), 30), + expires: this.environmentService.getCookieExpiresIn(), secure: this.environmentService.isHttps(), }); } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 9b22a048..5ecc8427 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -28,6 +28,10 @@ import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { + validateAllowedEmail, + validateSsoEnforcement, +} from '../../auth/auth.util'; @Injectable() export class WorkspaceInvitationService { @@ -63,19 +67,19 @@ export class WorkspaceInvitationService { return result; } - async getInvitationById(invitationId: string, workspaceId: string) { + async getInvitationById(invitationId: string, workspace: Workspace) { const invitation = await this.db .selectFrom('workspaceInvitations') .select(['id', 'email', 'createdAt']) .where('id', '=', invitationId) - .where('workspaceId', '=', workspaceId) + .where('workspaceId', '=', workspace.id) .executeTakeFirst(); if (!invitation) { throw new NotFoundException('Invitation not found'); } - return invitation; + return { ...invitation, enforceSso: workspace.enforceSso }; } async getInvitationTokenById(invitationId: string, workspaceId: string) { @@ -141,6 +145,10 @@ export class WorkspaceInvitationService { groupIds: validGroups?.map((group: Partial) => group.id), })); + if (invitesToInsert.length < 1) { + return; + } + invites = await trx .insertInto('workspaceInvitations') .values(invitesToInsert) @@ -169,12 +177,12 @@ export class WorkspaceInvitationService { } } - async acceptInvitation(dto: AcceptInviteDto, workspaceId: string) { + async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) { const invitation = await this.db .selectFrom('workspaceInvitations') .selectAll() .where('id', '=', dto.invitationId) - .where('workspaceId', '=', workspaceId) + .where('workspaceId', '=', workspace.id) .executeTakeFirst(); if (!invitation) { @@ -185,6 +193,9 @@ export class WorkspaceInvitationService { throw new BadRequestException('Invalid invitation token'); } + validateSsoEnforcement(workspace); + validateAllowedEmail(invitation.email, workspace); + let newUser: User; try { @@ -197,7 +208,7 @@ export class WorkspaceInvitationService { password: dto.password, role: invitation.role, invitedById: invitation.invitedById, - workspaceId: workspaceId, + workspaceId: workspace.id, }, trx, ); @@ -205,7 +216,7 @@ export class WorkspaceInvitationService { // add user to default group await this.groupUserRepo.addUserToDefaultGroup( newUser.id, - workspaceId, + workspace.id, trx, ); @@ -215,7 +226,7 @@ export class WorkspaceInvitationService { .selectFrom('groups') .select(['id', 'name']) .where('groups.id', 'in', invitation.groupIds) - .where('groups.workspaceId', '=', workspaceId) + .where('groups.workspaceId', '=', workspace.id) .execute(); if (validGroups && validGroups.length > 0) { @@ -256,7 +267,7 @@ export class WorkspaceInvitationService { // notify the inviter const invitedByUser = await this.userRepo.findById( invitation.invitedById, - workspaceId, + workspace.id, ); if (invitedByUser) { @@ -273,7 +284,9 @@ export class WorkspaceInvitationService { } if (this.environmentService.isCloud()) { - await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { workspaceId }); + await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, { + workspaceId: workspace.id, + }); } return this.tokenService.generateAccessToken(newUser); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index d6359893..694eaa44 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -32,6 +32,7 @@ import { AttachmentType } from 'src/core/attachment/attachment.constants'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { Queue } from 'bullmq'; +import { generateRandomSuffixNumbers } from '../../../common/helpers'; @Injectable() export class WorkspaceService { @@ -377,24 +378,20 @@ export class WorkspaceService { name: string, trx?: KyselyTransaction, ): Promise { - const generateRandomSuffix = (length: number) => - Math.random() - .toFixed(length) - .substring(2, 2 + length); - let subdomain = name .toLowerCase() - .replace(/[^a-z0-9]/g, '') - .substring(0, 20); + .replace(/[^a-z0-9-]/g, '') + .substring(0, 20) + .replace(/^-+|-+$/g, ''); //remove any hyphen at the start or end // Ensure we leave room for a random suffix. const maxSuffixLength = 6; if (subdomain.length < 4) { - subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`; + subdomain = `${subdomain}-${generateRandomSuffixNumbers(maxSuffixLength)}`; } if (DISALLOWED_HOSTNAMES.includes(subdomain)) { - subdomain = `workspace-${generateRandomSuffix(maxSuffixLength)}`; + subdomain = `workspace-${generateRandomSuffixNumbers(maxSuffixLength)}`; } let uniqueHostname = subdomain; @@ -408,7 +405,7 @@ export class WorkspaceService { break; } // Append a random suffix and retry. - const randomSuffix = generateRandomSuffix(maxSuffixLength); + const randomSuffix = generateRandomSuffixNumbers(maxSuffixLength); uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25); } diff --git a/apps/server/src/database/migrations/20250521T154949-file_tasks.ts b/apps/server/src/database/migrations/20250521T154949-file_tasks.ts new file mode 100644 index 00000000..523ae86b --- /dev/null +++ b/apps/server/src/database/migrations/20250521T154949-file_tasks.ts @@ -0,0 +1,39 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('file_tasks') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + // type (import, export) + .addColumn('type', 'varchar', (col) => col) + // source (generic, notion, confluence) + .addColumn('source', 'varchar', (col) => col) + // status (pending|processing|success|failed), + .addColumn('status', 'varchar', (col) => col) + .addColumn('file_name', 'varchar', (col) => col.notNull()) + .addColumn('file_path', 'varchar', (col) => col.notNull()) + .addColumn('file_size', 'int8', (col) => col) + .addColumn('file_ext', 'varchar', (col) => col) + .addColumn('error_message', 'varchar', (col) => col) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz', (col) => col) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('file_tasks').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 8c4cbd57..4545ebc4 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -122,6 +122,24 @@ export interface Comments { workspaceId: string; } +export interface FileTasks { + createdAt: Generated; + creatorId: string | null; + deletedAt: Timestamp | null; + errorMessage: string | null; + fileExt: string | null; + fileName: string; + filePath: string; + fileSize: Int8 | null; + id: Generated; + source: string | null; + spaceId: string | null; + status: string | null; + type: string | null; + updatedAt: Generated; + workspaceId: string; +} + export interface Groups { createdAt: Generated; creatorId: string | null; @@ -298,6 +316,7 @@ export interface DB { backlinks: Backlinks; billing: Billing; comments: Comments; + fileTasks: FileTasks; groups: Groups; groupUsers: GroupUsers; pageHistory: PageHistory; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 6cb55a11..db2c2823 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -17,6 +17,7 @@ import { AuthProviders, AuthAccounts, Shares, + FileTasks, } from './db'; // Workspace @@ -107,3 +108,8 @@ export type UpdatableAuthAccount = Updateable>; export type Share = Selectable; export type InsertableShare = Insertable; export type UpdatableShare = Updateable>; + +// File Task +export type FileTask = Selectable; +export type InsertableFileTask = Insertable; +export type UpdatableFileTask = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index b312008b..ffcae8db 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit b312008b4b7ed3d5862436b279d91aeddb6048d7 +Subproject commit ffcae8dbe760ab779733907861228f9b643e11b5 diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index ac26b4fb..639113a6 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import ms, { StringValue } from 'ms'; @Injectable() export class EnvironmentService { @@ -56,7 +57,18 @@ export class EnvironmentService { } getJwtTokenExpiresIn(): string { - return this.configService.get('JWT_TOKEN_EXPIRES_IN', '30d'); + return this.configService.get('JWT_TOKEN_EXPIRES_IN', '90d'); + } + + getCookieExpiresIn(): Date { + const expiresInStr = this.getJwtTokenExpiresIn(); + let msUntilExpiry: number; + try { + msUntilExpiry = ms(expiresInStr as StringValue); + } catch (err) { + msUntilExpiry = ms('90d'); + } + return new Date(Date.now() + msUntilExpiry); } getStorageDriver(): string { @@ -67,6 +79,10 @@ export class EnvironmentService { return this.configService.get('FILE_UPLOAD_SIZE_LIMIT', '50mb'); } + getFileImportSizeLimit(): string { + return this.configService.get('FILE_IMPORT_SIZE_LIMIT', '200mb'); + } + getAwsS3AccessKeyId(): string { return this.configService.get('AWS_S3_ACCESS_KEY_ID'); } diff --git a/apps/server/src/integrations/export/turndown-utils.ts b/apps/server/src/integrations/export/turndown-utils.ts index 44e606f3..54fdef12 100644 --- a/apps/server/src/integrations/export/turndown-utils.ts +++ b/apps/server/src/integrations/export/turndown-utils.ts @@ -1,5 +1,6 @@ import * as TurndownService from '@joplin/turndown'; import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm'; +import * as path from 'path'; export function turndown(html: string): string { const turndownService = new TurndownService({ @@ -23,6 +24,7 @@ export function turndown(html: string): string { mathInline, mathBlock, iframeEmbed, + video, ]); return turndownService.turndown(html).replaceAll('
', ' '); } @@ -87,8 +89,12 @@ function preserveDetail(turndownService: TurndownService) { } const detailsContent = Array.from(node.childNodes) - .filter(child => child.nodeName !== 'SUMMARY') - .map(child => (child.nodeType === 1 ? turndownService.turndown((child as HTMLElement).outerHTML) : child.textContent)) + .filter((child) => child.nodeName !== 'SUMMARY') + .map((child) => + child.nodeType === 1 + ? turndownService.turndown((child as HTMLElement).outerHTML) + : child.textContent, + ) .join(''); return `\n
\n${detailSummary}\n\n${detailsContent}\n\n
\n`; @@ -135,3 +141,16 @@ function iframeEmbed(turndownService: TurndownService) { }, }); } + +function video(turndownService: TurndownService) { + turndownService.addRule('video', { + filter: function (node: HTMLInputElement) { + return node.tagName === 'VIDEO'; + }, + replacement: function (content: any, node: HTMLInputElement) { + const src = node.getAttribute('src') || ''; + const name = path.basename(src); + return '[' + name + '](' + src + ')'; + }, + }); +} diff --git a/apps/server/src/integrations/import/dto/file-task-dto.ts b/apps/server/src/integrations/import/dto/file-task-dto.ts new file mode 100644 index 00000000..9cdea395 --- /dev/null +++ b/apps/server/src/integrations/import/dto/file-task-dto.ts @@ -0,0 +1,18 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class FileTaskIdDto { + @IsNotEmpty() + @IsUUID() + fileTaskId: string; +} + +export type ImportPageNode = { + id: string; + slugId: string; + name: string; + content: string; + position?: string | null; + parentPageId: string | null; + fileExtension: string; + filePath: string; +}; \ No newline at end of file diff --git a/apps/server/src/integrations/import/file-task.controller.ts b/apps/server/src/integrations/import/file-task.controller.ts new file mode 100644 index 00000000..305779b4 --- /dev/null +++ b/apps/server/src/integrations/import/file-task.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} 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 { + SpaceCaslAction, + SpaceCaslSubject, +} from '../../core/casl/interfaces/space-ability.type'; +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'; + +@Controller('file-tasks') +export class FileTaskController { + constructor( + private readonly spaceMemberRepo: SpaceMemberRepo, + private readonly spaceAbility: SpaceAbilityFactory, + @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 []; + } + + const fileTasks = await this.db + .selectFrom('fileTasks') + .selectAll() + .where('spaceId', 'in', userSpaceIds) + .execute(); + + if (!fileTasks) { + throw new NotFoundException('File task not found'); + } + + return fileTasks; + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('info') + async getFileTask(@Body() dto: FileTaskIdDto, @AuthUser() user: User) { + const fileTask = await this.db + .selectFrom('fileTasks') + .selectAll() + .where('id', '=', dto.fileTaskId) + .executeTakeFirst(); + + if (!fileTask || !fileTask.spaceId) { + throw new NotFoundException('File task not found'); + } + + const ability = await this.spaceAbility.createForUser( + user, + fileTask.spaceId, + ); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return fileTask; + } +} diff --git a/apps/server/src/integrations/import/import.controller.ts b/apps/server/src/integrations/import/import.controller.ts index 975301af..1adb82eb 100644 --- a/apps/server/src/integrations/import/import.controller.ts +++ b/apps/server/src/integrations/import/import.controller.ts @@ -21,8 +21,9 @@ import { import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import * as bytes from 'bytes'; import * as path from 'path'; -import { ImportService } from './import.service'; +import { ImportService } from './services/import.service'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { EnvironmentService } from '../environment/environment.service'; @Controller() export class ImportController { @@ -31,6 +32,7 @@ export class ImportController { constructor( private readonly importService: ImportService, private readonly spaceAbility: SpaceAbilityFactory, + private readonly environmentService: EnvironmentService, ) {} @UseInterceptors(FileInterceptor) @@ -44,18 +46,18 @@ export class ImportController { ) { const validFileExtensions = ['.md', '.html']; - const maxFileSize = bytes('100mb'); + const maxFileSize = bytes('10mb'); let file = null; try { file = await req.file({ - limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + limits: { fileSize: maxFileSize, fields: 4, files: 1 }, }); } catch (err: any) { this.logger.error(err.message); if (err?.statusCode === 413) { throw new BadRequestException( - `File too large. Exceeds the 100mb import limit`, + `File too large. Exceeds the 10mb import limit`, ); } } @@ -73,7 +75,7 @@ export class ImportController { const spaceId = file.fields?.spaceId?.value; if (!spaceId) { - throw new BadRequestException('spaceId or format not found'); + throw new BadRequestException('spaceId is required'); } const ability = await this.spaceAbility.createForUser(user, spaceId); @@ -83,4 +85,69 @@ export class ImportController { return this.importService.importPage(file, user.id, spaceId, workspace.id); } + + @UseInterceptors(FileInterceptor) + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('pages/import-zip') + async importZip( + @Req() req: any, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const validFileExtensions = ['.zip']; + + const maxFileSize = bytes(this.environmentService.getFileImportSizeLimit()); + + let file = null; + try { + file = await req.file({ + limits: { fileSize: maxFileSize, fields: 3, files: 1 }, + }); + } catch (err: any) { + this.logger.error(err.message); + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${this.environmentService.getFileImportSizeLimit()} import limit`, + ); + } + } + + if (!file) { + throw new BadRequestException('Failed to upload file'); + } + + if ( + !validFileExtensions.includes(path.extname(file.filename).toLowerCase()) + ) { + throw new BadRequestException('Invalid import file extension.'); + } + + const spaceId = file.fields?.spaceId?.value; + const source = file.fields?.source?.value; + + const validZipSources = ['generic', 'notion', 'confluence']; + if (!validZipSources.includes(source)) { + throw new BadRequestException( + 'Invalid import source. Import source must either be generic, notion or confluence.', + ); + } + + if (!spaceId) { + throw new BadRequestException('spaceId is required'); + } + + const ability = await this.spaceAbility.createForUser(user, spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.importService.importZip( + file, + source, + user.id, + spaceId, + workspace.id, + ); + } } diff --git a/apps/server/src/integrations/import/import.module.ts b/apps/server/src/integrations/import/import.module.ts index 60498808..40a49023 100644 --- a/apps/server/src/integrations/import/import.module.ts +++ b/apps/server/src/integrations/import/import.module.ts @@ -1,9 +1,22 @@ import { Module } from '@nestjs/common'; -import { ImportService } from './import.service'; +import { ImportService } from './services/import.service'; import { ImportController } from './import.controller'; +import { StorageModule } from '../storage/storage.module'; +import { FileTaskService } from './services/file-task.service'; +import { FileTaskProcessor } from './processors/file-task.processor'; +import { ImportAttachmentService } from './services/import-attachment.service'; +import { FileTaskController } from './file-task.controller'; +import { PageModule } from '../../core/page/page.module'; @Module({ - providers: [ImportService], - controllers: [ImportController], + providers: [ + ImportService, + FileTaskService, + FileTaskProcessor, + ImportAttachmentService, + ], + exports: [ImportService, ImportAttachmentService], + controllers: [ImportController, FileTaskController], + imports: [StorageModule, PageModule], }) export class ImportModule {} diff --git a/apps/server/src/integrations/import/processors/file-task.processor.ts b/apps/server/src/integrations/import/processors/file-task.processor.ts new file mode 100644 index 00000000..9431ccec --- /dev/null +++ b/apps/server/src/integrations/import/processors/file-task.processor.ts @@ -0,0 +1,76 @@ +import { Logger, OnModuleDestroy } from '@nestjs/common'; +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from 'src/integrations/queue/constants'; +import { FileTaskService } from '../services/file-task.service'; +import { FileTaskStatus } from '../utils/file.utils'; +import { StorageService } from '../../storage/storage.service'; + +@Processor(QueueName.FILE_TASK_QUEUE) +export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(FileTaskProcessor.name); + + constructor( + private readonly fileTaskService: FileTaskService, + private readonly storageService: StorageService, + ) { + super(); + } + + async process(job: Job): Promise { + try { + switch (job.name) { + case QueueJob.IMPORT_TASK: + await this.fileTaskService.processZIpImport(job.data.fileTaskId); + break; + case QueueJob.EXPORT_TASK: + // TODO: export task + break; + } + } catch (err) { + this.logger.error('File task failed', err); + throw err; + } + } + + @OnWorkerEvent('active') + onActive(job: Job) { + this.logger.debug(`Processing ${job.name} job`); + } + + @OnWorkerEvent('failed') + async onFailed(job: Job) { + this.logger.error( + `Error processing ${job.name} job. Reason: ${job.failedReason}`, + ); + + try { + const fileTaskId = job.data.fileTaskId; + await this.fileTaskService.updateTaskStatus( + fileTaskId, + FileTaskStatus.Failed, + job.failedReason, + ); + + const fileTask = await this.fileTaskService.getFileTask(fileTaskId); + if (fileTask) { + await this.storageService.delete(fileTask.filePath); + } + } catch (err) { + this.logger.error(err); + } + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.log( + `Completed ${job.name} job for File task ID ${job.data.fileTaskId}`, + ); + } + + async onModuleDestroy(): Promise { + if (this.worker) { + await this.worker.close(); + } + } +} diff --git a/apps/server/src/integrations/import/services/file-task.service.ts b/apps/server/src/integrations/import/services/file-task.service.ts new file mode 100644 index 00000000..f054017d --- /dev/null +++ b/apps/server/src/integrations/import/services/file-task.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as path from 'path'; +import { jsonToText } from '../../../collaboration/collaboration.util'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { + extractZip, + FileImportSource, + FileTaskStatus, +} from '../utils/file.utils'; +import { StorageService } from '../../storage/storage.service'; +import * as tmp from 'tmp-promise'; +import { pipeline } from 'node:stream/promises'; +import { createWriteStream } from 'node:fs'; +import { ImportService } from './import.service'; +import { promises as fs } from 'fs'; +import { generateSlugId } from '../../../common/helpers'; +import { v7 } from 'uuid'; +import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; +import { FileTask, InsertablePage } from '@docmost/db/types/entity.types'; +import { markdownToHtml } from '@docmost/editor-ext'; +import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils'; +import { formatImportHtml } from '../utils/import-formatter'; +import { + buildAttachmentCandidates, + collectMarkdownAndHtmlFiles, +} from '../utils/import.utils'; +import { executeTx } from '@docmost/db/utils'; +import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; +import { ImportAttachmentService } from './import-attachment.service'; +import { ModuleRef } from '@nestjs/core'; +import { PageService } from '../../../core/page/services/page.service'; +import { ImportPageNode } from '../dto/file-task-dto'; + +@Injectable() +export class FileTaskService { + private readonly logger = new Logger(FileTaskService.name); + + constructor( + private readonly storageService: StorageService, + private readonly importService: ImportService, + private readonly pageService: PageService, + private readonly backlinkRepo: BacklinkRepo, + @InjectKysely() private readonly db: KyselyDB, + private readonly importAttachmentService: ImportAttachmentService, + private moduleRef: ModuleRef, + ) {} + + async processZIpImport(fileTaskId: string): Promise { + const fileTask = await this.db + .selectFrom('fileTasks') + .selectAll() + .where('id', '=', fileTaskId) + .executeTakeFirst(); + + if (!fileTask) { + this.logger.log(`Import file task with ID ${fileTaskId} not found`); + return; + } + + if (fileTask.status === FileTaskStatus.Failed) { + return; + } + + if (fileTask.status === FileTaskStatus.Success) { + this.logger.log('Imported task already processed.'); + return; + } + + const { path: tmpZipPath, cleanup: cleanupTmpFile } = await tmp.file({ + prefix: 'docmost-import', + postfix: '.zip', + discardDescriptor: true, + }); + + const { path: tmpExtractDir, cleanup: cleanupTmpDir } = await tmp.dir({ + prefix: 'docmost-extract-', + unsafeCleanup: true, + }); + + try { + const fileStream = await this.storageService.readStream( + fileTask.filePath, + ); + await pipeline(fileStream, createWriteStream(tmpZipPath)); + await extractZip(tmpZipPath, tmpExtractDir); + } catch (err) { + await cleanupTmpFile(); + await cleanupTmpDir(); + + throw err; + } + + try { + if ( + fileTask.source === FileImportSource.Generic || + fileTask.source === FileImportSource.Notion + ) { + await this.processGenericImport({ + extractDir: tmpExtractDir, + fileTask, + }); + } + + if (fileTask.source === FileImportSource.Confluence) { + let ConfluenceModule: any; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + ConfluenceModule = require('./../../../ee/confluence-import/confluence-import.service'); + } catch (err) { + this.logger.error( + 'Confluence import requested but EE module not bundled in this build', + ); + return; + } + const confluenceImportService = this.moduleRef.get( + ConfluenceModule.ConfluenceImportService, + { strict: false }, + ); + + await confluenceImportService.processConfluenceImport({ + extractDir: tmpExtractDir, + fileTask, + }); + } + try { + await this.updateTaskStatus(fileTaskId, FileTaskStatus.Success, null); + await cleanupTmpFile(); + await cleanupTmpDir(); + // delete stored file on success + await this.storageService.delete(fileTask.filePath); + } catch (err) { + this.logger.error( + `Failed to delete import file from storage. Task ID: ${fileTaskId}`, + err, + ); + } + } catch (err) { + await cleanupTmpFile(); + await cleanupTmpDir(); + + throw err; + } + } + + async processGenericImport(opts: { + extractDir: string; + fileTask: FileTask; + }): Promise { + const { extractDir, fileTask } = opts; + const allFiles = await collectMarkdownAndHtmlFiles(extractDir); + const attachmentCandidates = await buildAttachmentCandidates(extractDir); + + const pagesMap = new Map(); + + for (const absPath of allFiles) { + const relPath = path + .relative(extractDir, absPath) + .split(path.sep) + .join('/'); // normalize to forward-slashes + const ext = path.extname(relPath).toLowerCase(); + let content = await fs.readFile(absPath, 'utf-8'); + + if (ext.toLowerCase() === '.md') { + content = await markdownToHtml(content); + } + + pagesMap.set(relPath, { + id: v7(), + slugId: generateSlugId(), + name: path.basename(relPath, ext), + content, + parentPageId: null, + fileExtension: ext, + filePath: relPath, + }); + } + + // parent/child linking + pagesMap.forEach((page, filePath) => { + const segments = filePath.split('/'); + segments.pop(); + let parentPage = null; + while (segments.length) { + const tryMd = segments.join('/') + '.md'; + const tryHtml = segments.join('/') + '.html'; + if (pagesMap.has(tryMd)) { + parentPage = pagesMap.get(tryMd)!; + break; + } + if (pagesMap.has(tryHtml)) { + parentPage = pagesMap.get(tryHtml)!; + break; + } + segments.pop(); + } + if (parentPage) page.parentPageId = parentPage.id; + }); + + // generate position keys + const siblingsMap = new Map(); + + pagesMap.forEach((page) => { + const group = siblingsMap.get(page.parentPageId) ?? []; + group.push(page); + siblingsMap.set(page.parentPageId, group); + }); + + // get root pages + const rootSibs = siblingsMap.get(null); + + if (rootSibs?.length) { + rootSibs.sort((a, b) => a.name.localeCompare(b.name)); + + // get first position key from the server + const nextPosition = await this.pageService.nextPagePosition( + fileTask.spaceId, + ); + + let prevPos: string | null = null; + rootSibs.forEach((page, idx) => { + if (idx === 0) { + page.position = nextPosition; + } else { + page.position = generateJitteredKeyBetween(prevPos, null); + } + prevPos = page.position; + }); + } + + // non-root buckets (children & deeper levels) + siblingsMap.forEach((sibs, parentId) => { + if (parentId === null) return; // root already done + + sibs.sort((a, b) => a.name.localeCompare(b.name)); + + let prevPos: string | null = null; + for (const page of sibs) { + page.position = generateJitteredKeyBetween(prevPos, null); + prevPos = page.position; + } + }); + + // internal page links + const filePathToPageMetaMap = new Map< + string, + { id: string; title: string; slugId: string } + >(); + pagesMap.forEach((page) => { + filePathToPageMetaMap.set(page.filePath, { + id: page.id, + title: page.name, + slugId: page.slugId, + }); + }); + + const pageResults = await Promise.all( + Array.from(pagesMap.values()).map(async (page) => { + const htmlContent = + await this.importAttachmentService.processAttachments({ + html: page.content, + pageRelativePath: page.filePath, + extractDir, + pageId: page.id, + fileTask, + attachmentCandidates, + }); + + const { html, backlinks } = await formatImportHtml({ + html: htmlContent, + currentFilePath: page.filePath, + filePathToPageMetaMap: filePathToPageMetaMap, + creatorId: fileTask.creatorId, + sourcePageId: page.id, + workspaceId: fileTask.workspaceId, + }); + + const pmState = getProsemirrorContent( + await this.importService.processHTML(html), + ); + + const { title, prosemirrorJson } = + this.importService.extractTitleAndRemoveHeading(pmState); + + const insertablePage: InsertablePage = { + id: page.id, + slugId: page.slugId, + title: title || page.name, + content: prosemirrorJson, + textContent: jsonToText(prosemirrorJson), + ydoc: await this.importService.createYdoc(prosemirrorJson), + position: page.position!, + spaceId: fileTask.spaceId, + workspaceId: fileTask.workspaceId, + creatorId: fileTask.creatorId, + lastUpdatedById: fileTask.creatorId, + parentPageId: page.parentPageId, + }; + + return { insertablePage, backlinks }; + }), + ); + + const insertablePages = pageResults.map((r) => r.insertablePage); + const insertableBacklinks = pageResults.flatMap((r) => r.backlinks); + + if (insertablePages.length < 1) return; + const validPageIds = new Set(insertablePages.map((row) => row.id)); + const filteredBacklinks = insertableBacklinks.filter( + ({ sourcePageId, targetPageId }) => + validPageIds.has(sourcePageId) && validPageIds.has(targetPageId), + ); + + await executeTx(this.db, async (trx) => { + await trx.insertInto('pages').values(insertablePages).execute(); + + if (filteredBacklinks.length > 0) { + await this.backlinkRepo.insertBacklink(filteredBacklinks, trx); + } + }); + } + + async getFileTask(fileTaskId: string) { + return this.db + .selectFrom('fileTasks') + .selectAll() + .where('id', '=', fileTaskId) + .executeTakeFirst(); + } + + async updateTaskStatus( + fileTaskId: string, + status: FileTaskStatus, + errorMessage?: string, + ) { + try { + await this.db + .updateTable('fileTasks') + .set({ status: status, errorMessage, updatedAt: new Date() }) + .where('id', '=', fileTaskId) + .execute(); + } catch (err) { + this.logger.error(err); + } + } +} diff --git a/apps/server/src/integrations/import/services/import-attachment.service.ts b/apps/server/src/integrations/import/services/import-attachment.service.ts new file mode 100644 index 00000000..cd9039e2 --- /dev/null +++ b/apps/server/src/integrations/import/services/import-attachment.service.ts @@ -0,0 +1,303 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as path from 'path'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { cleanUrlString } from '../utils/file.utils'; +import { StorageService } from '../../storage/storage.service'; +import { createReadStream } from 'node:fs'; +import { promises as fs } from 'fs'; +import { getMimeType, sanitizeFileName } from '../../../common/helpers'; +import { v7 } from 'uuid'; +import { FileTask } from '@docmost/db/types/entity.types'; +import { getAttachmentFolderPath } from '../../../core/attachment/attachment.utils'; +import { AttachmentType } from '../../../core/attachment/attachment.constants'; +import { unwrapFromParagraph } from '../utils/import-formatter'; +import { resolveRelativeAttachmentPath } from '../utils/import.utils'; +import { load } from 'cheerio'; + +@Injectable() +export class ImportAttachmentService { + private readonly logger = new Logger(ImportAttachmentService.name); + + constructor( + private readonly storageService: StorageService, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async processAttachments(opts: { + html: string; + pageRelativePath: string; + extractDir: string; + pageId: string; + fileTask: FileTask; + attachmentCandidates: Map; + }): Promise { + const { + html, + pageRelativePath, + extractDir, + pageId, + fileTask, + attachmentCandidates, + } = opts; + + const attachmentTasks: Promise[] = []; + + /** + * Cache keyed by the *relative* path that appears in the HTML. + * Ensures we upload (and DB-insert) each attachment at most once, + * even if it’s referenced multiple times on the page. + */ + const processed = new Map< + string, + { + attachmentId: string; + storageFilePath: string; + apiFilePath: string; + fileNameWithExt: string; + abs: string; + } + >(); + + const uploadOnce = (relPath: string) => { + const abs = attachmentCandidates.get(relPath)!; + const attachmentId = v7(); + const ext = path.extname(abs); + + const fileNameWithExt = + sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase(); + + const storageFilePath = `${getAttachmentFolderPath( + AttachmentType.File, + fileTask.workspaceId, + )}/${attachmentId}/${fileNameWithExt}`; + + const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`; + + attachmentTasks.push( + (async () => { + const fileStream = createReadStream(abs); + await this.storageService.uploadStream(storageFilePath, fileStream); + const stat = await fs.stat(abs); + + await this.db + .insertInto('attachments') + .values({ + id: attachmentId, + filePath: storageFilePath, + fileName: fileNameWithExt, + fileSize: stat.size, + mimeType: getMimeType(fileNameWithExt), + type: 'file', + fileExt: ext, + creatorId: fileTask.creatorId, + workspaceId: fileTask.workspaceId, + pageId, + spaceId: fileTask.spaceId, + }) + .execute(); + })(), + ); + + return { + attachmentId, + storageFilePath, + apiFilePath, + fileNameWithExt, + abs, + }; + }; + + /** + * – Returns cached data if we’ve already processed this path. + * – Otherwise calls `uploadOnce`, stores the result, and returns it. + */ + const processFile = (relPath: string) => { + const cached = processed.get(relPath); + if (cached) return cached; + + const fresh = uploadOnce(relPath); + processed.set(relPath, fresh); + return fresh; + }; + + const pageDir = path.dirname(pageRelativePath); + const $ = load(html); + + // image + for (const imgEl of $('img').toArray()) { + const $img = $(imgEl); + const src = cleanUrlString($img.attr('src') ?? '')!; + if (!src || src.startsWith('http')) continue; + + const relPath = resolveRelativeAttachmentPath( + src, + pageDir, + attachmentCandidates, + ); + if (!relPath) continue; + + const { attachmentId, apiFilePath, abs } = processFile(relPath); + const stat = await fs.stat(abs); + + const width = $img.attr('width') ?? '100%'; + const align = $img.attr('data-align') ?? 'center'; + + $img + .attr('src', apiFilePath) + .attr('data-attachment-id', attachmentId) + .attr('data-size', stat.size.toString()) + .attr('width', width) + .attr('data-align', align); + + unwrapFromParagraph($, $img); + } + + // video + for (const vidEl of $('video').toArray()) { + const $vid = $(vidEl); + const src = cleanUrlString($vid.attr('src') ?? '')!; + if (!src || src.startsWith('http')) continue; + + const relPath = resolveRelativeAttachmentPath( + src, + pageDir, + attachmentCandidates, + ); + if (!relPath) continue; + + const { attachmentId, apiFilePath, abs } = processFile(relPath); + const stat = await fs.stat(abs); + + const width = $vid.attr('width') ?? '100%'; + const align = $vid.attr('data-align') ?? 'center'; + + $vid + .attr('src', apiFilePath) + .attr('data-attachment-id', attachmentId) + .attr('data-size', stat.size.toString()) + .attr('width', width) + .attr('data-align', align); + + unwrapFromParagraph($, $vid); + } + + //
+ for (const el of $('div[data-type="attachment"]').toArray()) { + const $oldDiv = $(el); + const rawUrl = cleanUrlString($oldDiv.attr('data-attachment-url') ?? '')!; + if (!rawUrl || rawUrl.startsWith('http')) continue; + + const relPath = resolveRelativeAttachmentPath( + rawUrl, + pageDir, + attachmentCandidates, + ); + if (!relPath) continue; + + const { attachmentId, apiFilePath, abs } = processFile(relPath); + const stat = await fs.stat(abs); + const fileName = path.basename(abs); + const mime = getMimeType(abs); + + const $newDiv = $('
') + .attr('data-type', 'attachment') + .attr('data-attachment-url', apiFilePath) + .attr('data-attachment-name', fileName) + .attr('data-attachment-mime', mime) + .attr('data-attachment-size', stat.size.toString()) + .attr('data-attachment-id', attachmentId); + + $oldDiv.replaceWith($newDiv); + unwrapFromParagraph($, $newDiv); + } + + // rewrite other attachments via + for (const aEl of $('a').toArray()) { + const $a = $(aEl); + const href = cleanUrlString($a.attr('href') ?? '')!; + if (!href || href.startsWith('http')) continue; + + const relPath = resolveRelativeAttachmentPath( + href, + pageDir, + attachmentCandidates, + ); + if (!relPath) continue; + + const { attachmentId, apiFilePath, abs } = processFile(relPath); + const stat = await fs.stat(abs); + const ext = path.extname(relPath).toLowerCase(); + + if (ext === '.mp4') { + const $video = $('
') + .attr('data-type', 'attachment') + .attr('data-attachment-url', apiFilePath) + .attr('data-attachment-name', attachmentName) + .attr('data-attachment-mime', getMimeType(abs)) + .attr('data-attachment-size', stat.size.toString()) + .attr('data-attachment-id', attachmentId); + + $a.replaceWith($div); + unwrapFromParagraph($, $div); + } + } + + // excalidraw and drawio + for (const type of ['excalidraw', 'drawio'] as const) { + for (const el of $(`div[data-type="${type}"]`).toArray()) { + const $oldDiv = $(el); + const rawSrc = cleanUrlString($oldDiv.attr('data-src') ?? '')!; + if (!rawSrc || rawSrc.startsWith('http')) continue; + + const relPath = resolveRelativeAttachmentPath( + rawSrc, + pageDir, + attachmentCandidates, + ); + if (!relPath) continue; + + const { attachmentId, apiFilePath, abs } = processFile(relPath); + const stat = await fs.stat(abs); + const fileName = path.basename(abs); + + const width = $oldDiv.attr('data-width') || '100%'; + const align = $oldDiv.attr('data-align') || 'center'; + + const $newDiv = $('
') + .attr('data-type', type) + .attr('data-src', apiFilePath) + .attr('data-title', fileName) + .attr('data-width', width) + .attr('data-size', stat.size.toString()) + .attr('data-align', align) + .attr('data-attachment-id', attachmentId); + + $oldDiv.replaceWith($newDiv); + unwrapFromParagraph($, $newDiv); + } + } + + // wait for all uploads & DB inserts + try { + await Promise.all(attachmentTasks); + } catch (err) { + this.logger.log('Import attachment upload error', err); + } + + return $.root().html() || ''; + } +} diff --git a/apps/server/src/integrations/import/import.service.ts b/apps/server/src/integrations/import/services/import.service.ts similarity index 61% rename from apps/server/src/integrations/import/import.service.ts rename to apps/server/src/integrations/import/services/import.service.ts index f77df0dc..a3da4918 100644 --- a/apps/server/src/integrations/import/import.service.ts +++ b/apps/server/src/integrations/import/services/import.service.ts @@ -4,16 +4,27 @@ import { MultipartFile } from '@fastify/multipart'; import { sanitize } from 'sanitize-filename-ts'; import * as path from 'path'; import { - htmlToJson, jsonToText, + htmlToJson, + jsonToText, tiptapExtensions, -} from '../../collaboration/collaboration.util'; +} from '../../../collaboration/collaboration.util'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; -import { generateSlugId } from '../../common/helpers'; +import { generateSlugId, sanitizeFileName } from '../../../common/helpers'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { TiptapTransformer } from '@hocuspocus/transformer'; import * as Y from 'yjs'; -import { markdownToHtml } from "@docmost/editor-ext"; +import { markdownToHtml } from '@docmost/editor-ext'; +import { + FileTaskStatus, + FileTaskType, + getFileTaskFolderPath, +} from '../utils/file.utils'; +import { v7 as uuid7 } from 'uuid'; +import { StorageService } from '../../storage/storage.service'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { QueueJob, QueueName } from '../../queue/constants'; @Injectable() export class ImportService { @@ -21,7 +32,10 @@ export class ImportService { constructor( private readonly pageRepo: PageRepo, + private readonly storageService: StorageService, @InjectKysely() private readonly db: KyselyDB, + @InjectQueue(QueueName.FILE_TASK_QUEUE) + private readonly fileTaskQueue: Queue, ) {} async importPage( @@ -113,7 +127,7 @@ export class ImportService { async createYdoc(prosemirrorJson: any): Promise { if (prosemirrorJson) { - this.logger.debug(`Converting prosemirror json state to ydoc`); + // this.logger.debug(`Converting prosemirror json state to ydoc`); const ydoc = TiptapTransformer.toYdoc( prosemirrorJson, @@ -129,20 +143,34 @@ export class ImportService { } extractTitleAndRemoveHeading(prosemirrorState: any) { - let title = null; + let title: string | null = null; + + const content = prosemirrorState.content ?? []; if ( - prosemirrorState?.content?.length > 0 && - prosemirrorState.content[0].type === 'heading' && - prosemirrorState.content[0].attrs?.level === 1 + content.length > 0 && + content[0].type === 'heading' && + content[0].attrs?.level === 1 ) { - title = prosemirrorState.content[0].content[0].text; - - // remove h1 header node from state - prosemirrorState.content.shift(); + title = content[0].content?.[0]?.text ?? null; + content.shift(); } - return { title, prosemirrorJson: prosemirrorState }; + // ensure at least one paragraph + if (content.length === 0) { + content.push({ + type: 'paragraph', + content: [], + }); + } + + return { + title, + prosemirrorJson: { + ...prosemirrorState, + content, + }, + }; } async getNewPagePosition(spaceId: string): Promise { @@ -161,4 +189,52 @@ export class ImportService { return generateJitteredKeyBetween(null, null); } } + + async importZip( + filePromise: Promise, + source: string, + userId: string, + spaceId: string, + workspaceId: string, + ) { + const file = await filePromise; + const fileBuffer = await file.toBuffer(); + const fileExtension = path.extname(file.filename).toLowerCase(); + const fileName = sanitizeFileName( + path.basename(file.filename, fileExtension), + ); + const fileSize = fileBuffer.length; + + const fileNameWithExt = fileName + fileExtension; + + const fileTaskId = uuid7(); + const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`; + + // upload file + await this.storageService.upload(filePath, fileBuffer); + + const fileTask = await this.db + .insertInto('fileTasks') + .values({ + id: fileTaskId, + type: FileTaskType.Import, + source: source, + status: FileTaskStatus.Processing, + fileName: fileNameWithExt, + filePath: filePath, + fileSize: fileSize, + fileExt: 'zip', + creatorId: userId, + spaceId: spaceId, + workspaceId: workspaceId, + }) + .returningAll() + .executeTakeFirst(); + + await this.fileTaskQueue.add(QueueJob.IMPORT_TASK, { + fileTaskId: fileTaskId, + }); + + return fileTask; + } } diff --git a/apps/server/src/integrations/import/utils/file.utils.ts b/apps/server/src/integrations/import/utils/file.utils.ts new file mode 100644 index 00000000..b3d39cda --- /dev/null +++ b/apps/server/src/integrations/import/utils/file.utils.ts @@ -0,0 +1,187 @@ +import * as yauzl from 'yauzl'; +import * as path from 'path'; +import * as fs from 'node:fs'; + +export enum FileTaskType { + Import = 'import', + Export = 'export', +} + +export enum FileImportSource { + Generic = 'generic', + Notion = 'notion', + Confluence = 'confluence', +} + +export enum FileTaskStatus { + Processing = 'processing', + Success = 'success', + Failed = 'failed', +} + +export function getFileTaskFolderPath( + type: FileTaskType, + workspaceId: string, +): string { + switch (type) { + case FileTaskType.Import: + return `${workspaceId}/imports`; + case FileTaskType.Export: + return `${workspaceId}/exports`; + } +} + +/** + * Extracts a ZIP archive. + */ +export async function extractZip( + source: string, + target: string, +): Promise { + return extractZipInternal(source, target, true); +} + +/** + * Internal helper to extract a ZIP, with optional single-nested-ZIP handling. + * @param source Path to the ZIP file + * @param target Directory to extract into + * @param allowNested Whether to check and unwrap one level of nested ZIP + */ +function extractZipInternal( + source: string, + target: string, + allowNested: boolean, +): Promise { + return new Promise((resolve, reject) => { + yauzl.open( + source, + { lazyEntries: true, decodeStrings: false, autoClose: true }, + (err, zipfile) => { + if (err) return reject(err); + + // Handle one level of nested ZIP if allowed + if (allowNested && zipfile.entryCount === 1) { + zipfile.readEntry(); + zipfile.once('entry', (entry) => { + const name = entry.fileName.toString('utf8').replace(/^\/+/, ''); + const isZip = + !/\/$/.test(entry.fileName) && + name.toLowerCase().endsWith('.zip'); + if (isZip) { + // temporary name to avoid overwriting file + const nestedPath = source.endsWith('.zip') + ? source.slice(0, -4) + '.inner.zip' + : source + '.inner.zip'; + + zipfile.openReadStream(entry, (openErr, rs) => { + if (openErr) return reject(openErr); + const ws = fs.createWriteStream(nestedPath); + rs.on('error', reject); + ws.on('error', reject); + ws.on('finish', () => { + zipfile.close(); + extractZipInternal(nestedPath, target, false) + .then(() => { + fs.unlinkSync(nestedPath); + resolve(); + }) + .catch(reject); + }); + rs.pipe(ws); + }); + } else { + zipfile.close(); + extractZipInternal(source, target, false).then(resolve, reject); + } + }); + zipfile.once('error', reject); + return; + } + + // Normal extraction + zipfile.readEntry(); + zipfile.on('entry', (entry) => { + const name = entry.fileName.toString('utf8'); + const safe = name.replace(/^\/+/, ''); + if (safe.startsWith('__MACOSX/')) { + zipfile.readEntry(); + return; + } + + const fullPath = path.join(target, safe); + + // Handle directories + if (/\/$/.test(name)) { + try { + fs.mkdirSync(fullPath, { recursive: true }); + } catch (mkdirErr: any) { + if (mkdirErr.code === 'ENAMETOOLONG') { + console.warn(`Skipping directory (path too long): ${fullPath}`); + zipfile.readEntry(); + return; + } + return reject(mkdirErr); + } + zipfile.readEntry(); + return; + } + + // Handle files + try { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + } catch (mkdirErr: any) { + if (mkdirErr.code === 'ENAMETOOLONG') { + console.warn( + `Skipping file directory creation (path too long): ${fullPath}`, + ); + zipfile.readEntry(); + return; + } + return reject(mkdirErr); + } + + zipfile.openReadStream(entry, (openErr, rs) => { + if (openErr) return reject(openErr); + + let ws: fs.WriteStream; + try { + ws = fs.createWriteStream(fullPath); + } catch (openWsErr: any) { + if (openWsErr.code === 'ENAMETOOLONG') { + console.warn( + `Skipping file write (path too long): ${fullPath}`, + ); + zipfile.readEntry(); + return; + } + return reject(openWsErr); + } + + rs.on('error', (err) => reject(err)); + ws.on('error', (err) => { + if ((err as any).code === 'ENAMETOOLONG') { + console.warn( + `Skipping file write on stream (path too long): ${fullPath}`, + ); + zipfile.readEntry(); + } else { + reject(err); + } + }); + ws.on('finish', () => zipfile.readEntry()); + rs.pipe(ws); + }); + }); + + zipfile.on('end', () => resolve()); + zipfile.on('error', (err) => reject(err)); + }, + ); + }); +} + +export function cleanUrlString(url: string): string { + if (!url) return null; + const [mainUrl] = url.split('?', 1); + return mainUrl; +} diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts new file mode 100644 index 00000000..92291d39 --- /dev/null +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -0,0 +1,254 @@ +import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; +import * as path from 'path'; +import { v7 } from 'uuid'; +import { InsertableBacklink } from '@docmost/db/types/entity.types'; +import { Cheerio, CheerioAPI, load } from 'cheerio'; + +export async function formatImportHtml(opts: { + html: string; + currentFilePath: string; + filePathToPageMetaMap: Map< + string, + { id: string; title: string; slugId: string } + >; + creatorId: string; + sourcePageId: string; + workspaceId: string; + pageDir?: string; + attachmentCandidates?: string[]; +}): Promise<{ html: string; backlinks: InsertableBacklink[] }> { + const { + html, + currentFilePath, + filePathToPageMetaMap, + creatorId, + sourcePageId, + workspaceId, + } = opts; + const $: CheerioAPI = load(html); + const $root: Cheerio = $.root(); + + notionFormatter($, $root); + defaultHtmlFormatter($, $root); + + const backlinks = await rewriteInternalLinksToMentionHtml( + $, + $root, + currentFilePath, + filePathToPageMetaMap, + creatorId, + sourcePageId, + workspaceId, + ); + + return { + html: $root.html() || '', + backlinks, + }; +} + +export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio) { + $root.find('a[href]').each((_, el) => { + const $el = $(el); + const url = $el.attr('href')!; + const { provider } = getEmbedUrlAndProvider(url); + if (provider === 'iframe') return; + + const embed = `
`; + $el.replaceWith(embed); + }); + + $root.find('iframe[src]').each((_, el) => { + const $el = $(el); + const url = $el.attr('src')!; + const { provider } = getEmbedUrlAndProvider(url); + + const embed = `
`; + $el.replaceWith(embed); + }); +} + +export function notionFormatter($: CheerioAPI, $root: Cheerio) { + // remove empty description paragraphs + $root.find('p.page-description').each((_, el) => { + if (!$(el).text().trim()) $(el).remove(); + }); + + // block math → mathBlock + $root.find('figure.equation').each((_: any, fig: any) => { + const $fig = $(fig); + const tex = $fig + .find('annotation[encoding="application/x-tex"]') + .text() + .trim(); + const $math = $('
') + .attr('data-type', 'mathBlock') + .attr('data-katex', 'true') + .text(tex); + $fig.replaceWith($math); + }); + + // inline math → mathInline + $root.find('span.notion-text-equation-token').each((_, tok) => { + const $tok = $(tok); + const $prev = $tok.prev('style'); + if ($prev.length) $prev.remove(); + const tex = $tok + .find('annotation[encoding="application/x-tex"]') + .text() + .trim(); + const $inline = $('') + .attr('data-type', 'mathInline') + .attr('data-katex', 'true') + .text(tex); + $tok.replaceWith($inline); + }); + + // callouts + $root + .find('figure.callout') + .get() + .reverse() + .forEach((fig) => { + const $fig = $(fig); + const $content = $fig.find('div').eq(1); + if (!$content.length) return; + const $wrapper = $('
') + .attr('data-type', 'callout') + .attr('data-callout-type', 'info'); + // @ts-ignore + $content.children().each((_, child) => $wrapper.append(child)); + $fig.replaceWith($wrapper); + }); + + // to-do lists + $root.find('ul.to-do-list').each((_, list) => { + const $old = $(list); + const $new = $('