Compare commits

...

14 Commits

Author SHA1 Message Date
Philip Okugbe 08e94eb3c1 update dependencies (#1902) 2026-02-03 15:15:23 -08:00
Philipinho 5a14186f1c fix global diff css 2026-02-03 13:47:56 -08:00
Philipinho 6a0bb8d4cb v0.25.0 2026-02-03 13:18:03 -08:00
Philip Okugbe fba9f4cb2b New Crowdin updates (#1896)
* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Japanese)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2026-02-03 13:16:27 -08:00
Philipinho d8f7c4a822 cleanup 2026-02-03 13:12:39 -08:00
Philipinho 202685b39f fix translation 2026-02-03 13:09:56 -08:00
Philip Okugbe fc4a428208 fix(deps): update dependencies (#1898) 2026-02-03 13:04:00 -08:00
Philip Okugbe 5506eb194b feat: page history diff (#1891)
* Show actual history changes
* V2 - WIP
* feat: page history diff
* fix: exclude content from history listing

---------

Co-authored-by: Jason Norwood-Young <jason@10layer.com>
2026-02-03 11:55:20 -08:00
Philipinho f32bb298e0 v0.25.0-beta.1 2026-01-30 23:09:01 +00:00
Pleasure1234 3178cad796 fix: handle empty replace term in search and replace functionality (#1562)
- Fix 'Empty text nodes are not allowed' error when replace field is empty
- Update both replace() and replaceAll() functions to check for empty replaceTerm
2026-01-30 22:37:22 +00:00
Philipinho 9d7f8c62c5 sync 2026-01-30 22:31:49 +00:00
Philip Okugbe 78b1c1a453 feat: switch to cursor pagination (#1884)
* add cursor pagination function

* support custom order modifier
* refactor returned object

* feat(db): migrate paginated endpoints to cursor-based pagination

* sync

* support hasPrevPage boolean

* feat(client): migrate pagination from offset to cursor-based

* support beforeCursor/prevCursor

* wrap search results in items array for API consistency
2026-01-30 19:28:54 +00:00
Philip Okugbe 96ed98619f feat: add IPv6 support via configurable HOST binding (#1885) 2026-01-30 00:33:10 +00:00
Philip Okugbe 60501de992 fix: missing logs on OnApplicationBootstrap hook (#1882)
* - fix: set default Nest logger and bufferLogs to false for pino compatibility
- handle redis error event

* fix collab server logging too
2026-01-29 09:25:23 +00:00
101 changed files with 3665 additions and 1888 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.24.1", "version": "0.25.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -123,6 +123,8 @@
"page": "Seite", "page": "Seite",
"Page deleted successfully": "Seite erfolgreich gelöscht", "Page deleted successfully": "Seite erfolgreich gelöscht",
"Page history": "Seitengeschichte", "Page history": "Seitengeschichte",
"Select version": "Version auswählen",
"Highlight changes": "Änderungen hervorheben",
"Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.", "Page import is in progress. Please do not close this tab.": "Der Seitenimport läuft. Bitte schließen Sie diesen Tab nicht.",
"Pages": "Seiten", "Pages": "Seiten",
"pages": "Seiten", "pages": "Seiten",
@@ -123,6 +123,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.", "Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -123,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página eliminada con éxito", "Page deleted successfully": "Página eliminada con éxito",
"Page history": "Historial de la página", "Page history": "Historial de la página",
"Select version": "Seleccionar versión",
"Highlight changes": "Resaltar cambios",
"Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.", "Page import is in progress. Please do not close this tab.": "La importación de la página está en curso. Por favor, no cierre esta pestaña.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -123,6 +123,8 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page supprimée avec succès", "Page deleted successfully": "Page supprimée avec succès",
"Page history": "Historique de la page", "Page history": "Historique de la page",
"Select version": "Sélectionner la version",
"Highlight changes": "Mettre en évidence les changements",
"Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.", "Page import is in progress. Please do not close this tab.": "L'importation de la page est en cours. Veuillez ne pas fermer cet onglet.",
"Pages": "Pages", "Pages": "Pages",
"pages": "pages", "pages": "pages",
@@ -123,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina eliminata con successo", "Page deleted successfully": "Pagina eliminata con successo",
"Page history": "Cronologia della pagina", "Page history": "Cronologia della pagina",
"Select version": "Seleziona versione",
"Highlight changes": "Evidenzia modifiche",
"Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.", "Page import is in progress. Please do not close this tab.": "L'importazione della pagina è in corso. Si prega di non chiudere questa scheda.",
"Pages": "Pagine", "Pages": "Pagine",
"pages": "pagine", "pages": "pagine",
@@ -123,6 +123,8 @@
"page": "ページ", "page": "ページ",
"Page deleted successfully": "ページを削除しました", "Page deleted successfully": "ページを削除しました",
"Page history": "ページ履歴", "Page history": "ページ履歴",
"Select version": "バージョンを選択",
"Highlight changes": "変更を強調表示",
"Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください", "Page import is in progress. Please do not close this tab.": "ページをインポート中です。このタブを閉じないでください",
"Pages": "ページ", "Pages": "ページ",
"pages": "ページ", "pages": "ページ",
@@ -123,6 +123,8 @@
"page": "페이지", "page": "페이지",
"Page deleted successfully": "페이지 삭제 완료", "Page deleted successfully": "페이지 삭제 완료",
"Page history": "페이지 기록", "Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
"Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.", "Page import is in progress. Please do not close this tab.": "페이지 가져오기가 진행 중입니다. 이 탭을 닫지 마세요.",
"Pages": "페이지", "Pages": "페이지",
"pages": "페이지", "pages": "페이지",
@@ -123,6 +123,8 @@
"page": "pagina", "page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd", "Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis", "Page history": "Pagina geschiedenis",
"Select version": "Selecteer versie",
"Highlight changes": "Wijzigingen markeren",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.", "Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's", "Pages": "Pagina's",
"pages": "pagina's", "pages": "pagina's",
@@ -123,6 +123,8 @@
"page": "página", "page": "página",
"Page deleted successfully": "Página excluída com sucesso", "Page deleted successfully": "Página excluída com sucesso",
"Page history": "Histórico da página", "Page history": "Histórico da página",
"Select version": "Selecionar versão",
"Highlight changes": "Destacar alterações",
"Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.", "Page import is in progress. Please do not close this tab.": "A importação da página está em andamento. Por favor, não feche esta aba.",
"Pages": "Páginas", "Pages": "Páginas",
"pages": "páginas", "pages": "páginas",
@@ -123,6 +123,8 @@
"page": "страница", "page": "страница",
"Page deleted successfully": "Страница успешно удалена", "Page deleted successfully": "Страница успешно удалена",
"Page history": "История страницы", "Page history": "История страницы",
"Select version": "Выбрать версию",
"Highlight changes": "Выделить изменения",
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.", "Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
"Pages": "Страницы", "Pages": "Страницы",
"pages": "страницы", "pages": "страницы",
@@ -123,6 +123,8 @@
"page": "сторінка", "page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено", "Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки", "Page history": "Історія сторінки",
"Select version": "Вибрати версію",
"Highlight changes": "Підсвітити зміни",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.", "Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки", "Pages": "Сторінки",
"pages": "сторінки", "pages": "сторінки",
@@ -123,6 +123,8 @@
"page": "个页面", "page": "个页面",
"Page deleted successfully": "页面已成功删除", "Page deleted successfully": "页面已成功删除",
"Page history": "页面历史", "Page history": "页面历史",
"Select version": "选择版本",
"Highlight changes": "突出显示更改",
"Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。", "Page import is in progress. Please do not close this tab.": "页面导入正在进行中。请不要关闭此标签页。",
"Pages": "页面", "Pages": "页面",
"pages": "个页面", "pages": "个页面",
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface PagePaginationProps { export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean; hasPrevPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
onPageChange: (newPage: number) => void; onPrev: () => void;
onNext: () => void;
} }
export default function Paginate({ export default function Paginate({
currentPage,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onPageChange, onPrev,
onNext,
}: PagePaginationProps) { }: PagePaginationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={() => onPageChange(currentPage - 1)} onClick={onPrev}
disabled={!hasPrevPage} disabled={!hasPrevPage}
> >
{t("Prev")} {t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button <Button
variant="default" variant="default"
size="compact-sm" size="compact-sm"
onClick={() => onPageChange(currentPage + 1)} onClick={onNext}
disabled={!hasNextPage} disabled={!hasNextPage}
> >
{t("Next")} {t("Next")}
@@ -13,7 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key"; import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => { export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams; const params: QueryParams = { limit: 100, query: "" };
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +22,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => { export const prefetchSpaces = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["spaces", { page: 1 }], queryKey: ["spaces", {}],
queryFn: () => getSpaces({ page: 1 }), queryFn: () => getSpaces({}),
}); });
}; };
export const prefetchGroups = () => { export const prefetchGroups = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groups", { page: 1 }], queryKey: ["groups", {}],
queryFn: () => getGroups({ page: 1 }), queryFn: () => getGroups({}),
}); });
}; };
@@ -62,21 +62,21 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => { export const prefetchShares = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }], queryKey: ["share-list", {}],
queryFn: () => getShares({ page: 1, limit: 100 }), queryFn: () => getShares({}),
}); });
}; };
export const prefetchApiKeys = () => { export const prefetchApiKeys = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }], queryKey: ["api-key-list", {}],
queryFn: () => getApiKeys({ page: 1 }), queryFn: () => getApiKeys({}),
}); });
}; };
export const prefetchApiKeyManagement = () => { export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }], queryKey: ["api-key-list", { adminView: true }],
queryFn: () => getApiKeys({ page: 1, adminView: true }), queryFn: () => getApiKeys({ adminView: true }),
}); });
}; };
@@ -10,19 +10,19 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page }); const { data, isLoading } = useGetApiKeysQuery({ cursor });
const handleCreateSuccess = (response: IApiKey) => { const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response); setCreatedApiKey(response);
@@ -65,10 +65,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
@@ -10,20 +10,20 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal"; import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal"; import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts"; import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key"; import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx'; import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() { export default function WorkspaceApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null); const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false); const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false); const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null); const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true }); const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -76,10 +76,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null; return null;
} }
if (data?.length === 0) { if (data?.items.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>; return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
} }
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data {data?.items
.sort((a, b) => { .sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled); const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff; if (enabledDiff !== 0) return enabledDiff;
@@ -104,7 +104,11 @@ export default function SsoProviderList() {
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}> <Badge
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
{provider.type.toUpperCase()} {provider.type.toUpperCase()}
</Badge> </Badge>
</Table.Td> </Table.Td>
@@ -134,41 +138,41 @@ export default function SsoProviderList() {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
> >
<IconPencil size={16} /> <IconPencil size={16} />
</ActionIcon> </ActionIcon>
<Menu <Menu
transitionProps={{ transition: "pop" }} transitionProps={{ transition: "pop" }}
withArrow withArrow
position="bottom-end" position="bottom-end"
withinPortal withinPortal
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" color="gray"> <ActionIcon variant="subtle" color="gray">
<IconDots size={16} /> <IconDots size={16} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item <Menu.Item
onClick={() => handleEdit(provider)} onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />} leftSection={<IconPencil size={16} />}
> >
{t("Edit")} {t("Edit")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
onClick={() => openDeleteModal(provider.id)} onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />} leftSection={<IconTrash size={16} />}
color="red" color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE} disabled={provider.type === SSO_PROVIDER.GOOGLE}
> >
{t("Delete")} {t("Delete")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -13,8 +13,9 @@ import {
} from "@/ee/security/services/security-service.ts"; } from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> { export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
return useQuery({ return useQuery({
queryKey: ["sso-providers"], queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(), queryFn: () => getSsoProviders(),
@@ -1,5 +1,6 @@
import api from "@/lib/api-client.ts"; import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: { export async function getSsoProviderById(data: {
providerId: string; providerId: string;
@@ -8,8 +9,8 @@ export async function getSsoProviderById(data: {
return req.data; return req.data;
} }
export async function getSsoProviders(): Promise<IAuthProvider[]> { export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
const req = await api.post<IAuthProvider[]>("/sso/providers"); const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
return req.data; return req.data;
} }
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -1,26 +1,25 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts"; import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts"; import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetGroupsQuery({ page }); const { data, isLoading } = useGetGroupsQuery({ cursor });
const prefetchGroupMembers = (groupId: string) => { const prefetchGroupMembers = (groupId: string) => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: ["groupMembers", groupId, { page: 1 }], queryKey: ["groupMembers", groupId, {}],
queryFn: () => getGroupMembers(groupId, { page: 1 }), queryFn: () => getGroupMembers(groupId, {}),
}); });
}; };
@@ -85,10 +84,10 @@ export default function GroupList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -4,7 +4,7 @@ import {
useRemoveGroupMemberMutation, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import React, { useState } from "react"; import React from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@@ -12,12 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupId } = useParams(); const { groupId } = useParams();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { page }); const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -107,10 +108,10 @@ export default function GroupMembersList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -1,4 +1,9 @@
import { atom } from "jotai"; import { atom } from "jotai";
export const historyAtoms = atom<boolean>(false); export const historyAtoms = atom<boolean>(false);
export const activeHistoryIdAtom = atom<string>(''); export const activeHistoryIdAtom = atom<string>("");
export const activeHistoryPrevIdAtom = atom<string>("");
export const highlightChangesAtom = atom<boolean>(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom<DiffCounts | null>(null);
@@ -0,0 +1,69 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
position: relative;
overflow: hidden;
}
.selectorWrapper {
padding: var(--mantine-spacing-sm);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
flex-shrink: 0;
}
.selector {
width: 100%;
text-align: left;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
&:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
}
.dropdown {
max-height: rem(300px);
}
.option {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
&[data-combobox-selected] {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.editorArea {
flex: 1;
min-height: 0;
}
.editorContent {
padding: var(--mantine-spacing-md);
padding-bottom: rem(60px);
}
.actionButtons {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: rem(70px);
border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
flex-shrink: 0;
}
.floatingBar {
position: fixed;
bottom: var(--mantine-spacing-md);
left: 50%;
transform: translateX(-50%);
z-index: 100;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
white-space: nowrap;
}
@@ -0,0 +1,79 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
& :global(.history-diff-added) {
background: light-dark(#e1f3f2, #01654a) !important;
color: light-dark(#007b69, #cafff7) !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
& :global(.history-diff-deleted) {
text-decoration: line-through;
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-4));
background: light-dark(var(--mantine-color-red-0), rgba(255, 0, 0, 0.1));
border-radius: rem(2px);
padding: 0 rem(2px);
}
& :global(.history-diff-node-added) {
outline: rem(2px) solid
light-dark(var(--mantine-color-teal-5), var(--mantine-color-teal-7));
outline-offset: rem(2px);
border-radius: rem(4px);
}
& :global(.history-diff-node-deleted) {
opacity: 0.5;
outline: rem(2px) dashed
light-dark(var(--mantine-color-red-4), var(--mantine-color-red-6));
outline-offset: rem(4px);
border-radius: rem(4px);
}
}
.active {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -1,36 +1,203 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core"; import { Title } from "@mantine/core";
import classes from "./history.module.css"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
import { recreateTransform } from "@docmost/editor-ext";
import { DOMSerializer, Node } from "@tiptap/pm/model";
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
import { useAtom } from "jotai";
import {
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
export interface HistoryEditorProps { export interface HistoryEditorProps {
title: string; title: string;
content: any; content: any;
previousContent?: any;
} }
export function HistoryEditor({ title, content }: HistoryEditorProps) { export function HistoryEditor({
title,
content,
previousContent,
}: HistoryEditorProps) {
const [highlightChanges] = useAtom(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
const editor = useEditor({ const editor = useEditor({
extensions: mainExtensions, extensions: mainExtensions,
editable: false, editable: false,
}); });
useEffect(() => { useEffect(() => {
if (editor && content) { if (!editor || !content) return;
let decorationSet = DecorationSet.empty;
let addedCount = 0;
let deletedCount = 0;
if (previousContent) {
try {
const schema = editor.schema;
const oldContent = Node.fromJSON(schema, previousContent);
const newContent = Node.fromJSON(schema, content);
const tr = recreateTransform(oldContent, newContent, {
complexSteps: false,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(oldContent).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, newContent);
editor.commands.setContent(content);
const specialNodeTypes = new Set([
"image",
"attachment",
"video",
"excalidraw",
"drawio",
"mermaid",
"mathBlock",
"mathInline",
"table",
"details",
"callout",
]);
const decorations: Decoration[] = [];
let changeIndex = 0;
for (const change of changes) {
if (change.toB > change.fromB) {
changeIndex++;
const currentIndex = changeIndex;
let foundSpecialNode: { node: Node; pos: number } | null = null;
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromB <= pos && change.toB >= nodeEnd) {
foundSpecialNode = { node, pos };
return false;
}
}
});
if (foundSpecialNode) {
const nodeEnd =
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
decorations.push(
Decoration.node(foundSpecialNode.pos, nodeEnd, {
class: "history-diff-node-added",
"data-diff-index": String(currentIndex),
}),
);
} else {
decorations.push(
Decoration.inline(change.fromB, change.toB, {
class: "history-diff-added",
"data-diff-index": String(currentIndex),
}),
);
}
addedCount += 1;
}
if (change.toA > change.fromA) {
changeIndex++;
const currentIndex = changeIndex;
let foundDeletedNode: { node: Node; pos: number } | null = null;
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
if (specialNodeTypes.has(node.type.name)) {
const nodeEnd = pos + node.nodeSize;
if (change.fromA <= pos && change.toA >= nodeEnd) {
foundDeletedNode = { node, pos };
return false;
}
}
});
if (foundDeletedNode) {
decorations.push(
Decoration.widget(change.fromB, () => {
const wrapper = document.createElement("div");
wrapper.className = "history-diff-node-deleted";
wrapper.setAttribute("data-diff-index", String(currentIndex));
const serializer = DOMSerializer.fromSchema(schema);
const dom = serializer.serializeNode(foundDeletedNode!.node);
wrapper.appendChild(dom);
return wrapper;
}),
);
} else {
const deletedText = oldContent.textBetween(
change.fromA,
change.toA,
"",
);
if (deletedText) {
decorations.push(
Decoration.widget(change.fromB, () => {
const span = document.createElement("span");
span.className = "history-diff-deleted";
span.setAttribute("data-diff-index", String(currentIndex));
span.textContent = deletedText;
return span;
}),
);
}
}
deletedCount += 1;
}
}
decorationSet = DecorationSet.create(newContent, decorations);
} catch (e) {
console.error("History diff failed:", e);
editor.commands.setContent(content);
}
} else {
editor.commands.setContent(content); editor.commands.setContent(content);
} }
}, [title, content, editor]);
const total = addedCount + deletedCount;
// @ts-ignore
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () =>
highlightChanges ? decorationSet : DecorationSet.empty,
},
});
}, [
title,
content,
editor,
previousContent,
highlightChanges,
setDiffCounts,
]);
return ( return (
<> <div>
<div> <Title order={1}>{title}</Title>
<Title order={1}>{title}</Title> {editor && (
<EditorContent
{editor && ( editor={editor}
<EditorContent editor={editor} className={classes.historyEditor} /> className={historyClasses.historyEditor}
)} />
</div> )}
</> </div>
); );
} }
@@ -1,20 +1,42 @@
import { Text, Group, UnstyledButton } from "@mantine/core"; import { Text, Group, UnstyledButton } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
import { IPageHistory } from "@/features/page-history/types/page.types";
import { memo, useCallback } from "react";
interface HistoryItemProps { interface HistoryItemProps {
historyItem: any; historyItem: IPageHistory;
onSelect: (id: string) => void; index: number;
onSelect: (id: string, index: number) => void;
onHover?: (id: string, index: number) => void;
onHoverEnd?: () => void;
isActive: boolean; isActive: boolean;
} }
function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) { const HistoryItem = memo(function HistoryItem({
historyItem,
index,
onSelect,
onHover,
onHoverEnd,
isActive,
}: HistoryItemProps) {
const handleClick = useCallback(() => {
onSelect(historyItem.id, index);
}, [onSelect, historyItem.id, index]);
const handleMouseEnter = useCallback(() => {
onHover?.(historyItem.id, index);
}, [onHover, historyItem.id, index]);
return ( return (
<UnstyledButton <UnstyledButton
p="xs" p="xs"
onClick={() => onSelect(historyItem.id)} onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={onHoverEnd}
className={clsx(classes.history, { [classes.active]: isActive })} className={clsx(classes.history, { [classes.active]: isActive })}
> >
<Group wrap="nowrap"> <Group wrap="nowrap">
@@ -27,11 +49,11 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<CustomAvatar <CustomAvatar
size="sm" size="sm"
avatarUrl={historyItem.lastUpdatedBy.avatarUrl} avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
name={historyItem.lastUpdatedBy.name} name={historyItem.lastUpdatedBy?.name}
/> />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{historyItem.lastUpdatedBy.name} {historyItem.lastUpdatedBy?.name}
</Text> </Text>
</Group> </Group>
</div> </div>
@@ -39,6 +61,6 @@ function HistoryItem({ historyItem, onSelect, isActive }: HistoryItemProps) {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
} });
export default HistoryItem; export default HistoryItem;
@@ -1,29 +1,27 @@
import { import {
usePageHistoryListQuery, usePageHistoryListQuery,
usePageHistoryQuery, prefetchPageHistory,
} from "@/features/page-history/queries/page-history-query"; } from "@/features/page-history/queries/page-history-query";
import HistoryItem from "@/features/page-history/components/history-item"; import HistoryItem from "@/features/page-history/components/history-item";
import { import {
activeHistoryIdAtom, activeHistoryIdAtom,
activeHistoryPrevIdAtom,
historyAtoms, historyAtoms,
} from "@/features/page-history/atoms/history-atoms"; } from "@/features/page-history/atoms/history-atoms";
import { useAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button, ScrollArea, Group, Divider, Text } from "@mantine/core";
import { import {
pageEditorAtom, Button,
titleEditorAtom, ScrollArea,
} from "@/features/editor/atoms/editor-atoms"; Group,
import { modals } from "@mantine/modals"; Divider,
import { notifications } from "@mantine/notifications"; Loader,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { useHistoryRestore } from "@/features/page-history/hooks";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom"; const PREFETCH_DELAY_MS = 150;
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
interface Props { interface Props {
pageId: string; pageId: string;
@@ -32,62 +30,89 @@ interface Props {
function HistoryList({ pageId }: Props) { function HistoryList({ pageId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { const {
data: pageHistoryList, data: pageHistoryData,
isLoading, isLoading,
isError, isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId); } = usePageHistoryListQuery(pageId);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const [mainEditor] = useAtom(pageEditorAtom); const historyItems = useMemo(
const [mainEditorTitle] = useAtom(titleEditorAtom); () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
const [, setHistoryModalOpen] = useAtom(historyAtoms); [pageHistoryData],
);
const { spaceSlug } = useParams(); const loadMoreRef = useRef<HTMLDivElement>(null);
const { data: space } = useSpaceQuery(spaceSlug); const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const confirmModal = () => const { canRestore, confirmRestore } = useHistoryRestore();
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
const handleRestore = useCallback(() => { const clearPrefetchTimeout = useCallback(() => {
if (activeHistoryData) { if (prefetchTimeoutRef.current) {
mainEditorTitle clearTimeout(prefetchTimeoutRef.current);
.chain() prefetchTimeoutRef.current = null;
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
} }
}, [activeHistoryData]); }, []);
const handleHover = useCallback(
(historyId: string, index: number) => {
clearPrefetchTimeout();
prefetchTimeoutRef.current = setTimeout(() => {
prefetchPageHistory(historyId);
const prevId = historyItems[index + 1]?.id;
if (prevId) {
prefetchPageHistory(prevId);
}
}, PREFETCH_DELAY_MS);
},
[clearPrefetchTimeout, historyItems],
);
useEffect(() => { useEffect(() => {
if ( return clearPrefetchTimeout;
pageHistoryList && }, [clearPrefetchTimeout]);
pageHistoryList.items.length > 0 &&
!activeHistoryId const handleSelect = useCallback(
) { (id: string, index: number) => {
setActiveHistoryId(pageHistoryList.items[0].id); setActiveHistoryId(id);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
} }
}, [pageHistoryList]); }, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
@@ -97,34 +122,36 @@ function HistoryList({ pageId }: Props) {
return <div>{t("Error loading page history.")}</div>; return <div>{t("Error loading page history.")}</div>;
} }
if (!pageHistoryList || pageHistoryList.items.length === 0) { if (historyItems.length === 0) {
return <>{t("No page history saved yet.")}</>; return <>{t("No page history saved yet.")}</>;
} }
return ( return (
<div> <div>
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}> <ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
{pageHistoryList && {historyItems.map((historyItem, index) => (
pageHistoryList.items.map((historyItem, index) => ( <HistoryItem
<HistoryItem key={historyItem.id}
key={index} historyItem={historyItem}
historyItem={historyItem} index={index}
onSelect={setActiveHistoryId} onSelect={handleSelect}
isActive={historyItem.id === activeHistoryId} onHover={handleHover}
/> onHoverEnd={clearPrefetchTimeout}
))} isActive={historyItem.id === activeHistoryId}
/>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea> </ScrollArea>
{spaceAbility.cannot( {canRestore && (
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) ? null : (
<> <>
<Divider /> <Divider />
<Group p="xs" wrap="nowrap"> <Group p="xs" wrap="nowrap">
<Button size="compact-md" onClick={confirmModal}>
{t("Restore")}
</Button>
<Button <Button
variant="default" variant="default"
size="compact-md" size="compact-md"
@@ -132,6 +159,9 @@ function HistoryList({ pageId }: Props) {
> >
{t("Cancel")} {t("Cancel")}
</Button> </Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button>
</Group> </Group>
</> </>
)} )}
@@ -1,21 +1,45 @@
import { ScrollArea } from "@mantine/core"; import {
ActionIcon,
Group,
Paper,
ScrollArea,
Switch,
Text,
} from "@mantine/core";
import HistoryList from "@/features/page-history/components/history-list"; import HistoryList from "@/features/page-history/components/history-list";
import classes from "./history.module.css"; import classes from "./css/history.module.css";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view"; import HistoryView from "@/features/page-history/components/history-view";
import { useEffect } from "react"; import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
} }
export default function HistoryModalBody({ pageId }: Props) { export default function HistoryModalBody({ pageId }: Props) {
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const { t } = useTranslation();
const scrollViewportRef = useRef<HTMLDivElement>(null);
useEffect(() => { const activeHistoryId = useAtomValue(activeHistoryIdAtom);
setActiveHistoryId(""); const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
}, [pageId]); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return ( return (
<div className={classes.sidebarFlex}> <div className={classes.sidebarFlex}>
@@ -25,11 +49,63 @@ export default function HistoryModalBody({ pageId }: Props) {
</div> </div>
</nav> </nav>
<ScrollArea h="650" w="100%" scrollbarSize={5}> <div style={{ position: "relative", flex: 1 }}>
<div className={classes.sidebarRightSection}> <ScrollArea
{activeHistoryId && <HistoryView historyId={activeHistoryId} />} h={650}
</div> w="100%"
</ScrollArea> scrollbarSize={5}
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
px="md"
py="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
}}
>
<Group gap="md" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,208 @@
import {
ActionIcon,
Box,
Button,
Group,
Paper,
ScrollArea,
Select,
Switch,
Text,
} from "@mantine/core";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { formattedDate } from "@/lib/time";
import {
useDiffNavigation,
useHistoryReset,
useHistoryRestore,
} from "@/features/page-history/hooks";
import classes from "./css/history-mobile.module.css";
interface Props {
pageId: string;
pageTitle?: string;
}
export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
const { t } = useTranslation();
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const dropdownViewportRef = useRef<HTMLDivElement>(null);
const {
data: pageHistoryData,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => pageHistoryData?.pages.flatMap((page) => page.items) ?? [],
[pageHistoryData],
);
const selectData = useMemo(
() =>
historyItems.map((item) => ({
value: item.id,
label: formattedDate(new Date(item.createdAt)),
userName: item.lastUpdatedBy?.name,
})),
[historyItems],
);
useHistoryReset(pageId);
const { canRestore, confirmRestore } = useHistoryRestore();
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
}
}, [
historyItems,
activeHistoryId,
setActiveHistoryId,
setActiveHistoryPrevId,
]);
const handleDropdownScroll = useCallback(() => {
const viewport = dropdownViewportRef.current;
if (!viewport || !hasNextPage || isFetchingNextPage) return;
const { scrollTop, scrollHeight, clientHeight } = viewport;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isNearBottom) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const handleSelectVersion = useCallback(
(value: string | null) => {
if (!value) return;
const index = historyItems.findIndex((item) => item.id === value);
if (index >= 0) {
setActiveHistoryId(value);
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
}
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
if (isLoading) {
return null;
}
return (
<Box className={classes.container}>
<Box className={classes.selectorWrapper}>
<Select
data={selectData}
value={activeHistoryId}
onChange={handleSelectVersion}
placeholder={t("Select version")}
checkIconPosition="right"
maxDropdownHeight={300}
renderOption={({ option, checked }) => (
<Group justify="space-between" wrap="nowrap" w="100%">
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{(option as { userName?: string }).userName}
</Text>
</div>
{checked && <IconCheck size={16} />}
</Group>
)}
comboboxProps={{ withinPortal: false }}
scrollAreaProps={{
viewportRef: dropdownViewportRef,
onScrollPositionChange: handleDropdownScroll,
}}
/>
</Box>
<ScrollArea
className={classes.editorArea}
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box className={classes.editorContent}>
{activeHistoryId && <HistoryView />}
</Box>
</ScrollArea>
{canRestore && (
<Group className={classes.actionButtons} justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setHistoryModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={confirmRestore}>{t("Restore")}</Button>
</Group>
)}
{activeHistoryId && (
<Paper
shadow="sm"
radius="xl"
px="md"
py="xs"
className={classes.floatingBar}
>
<Group gap="sm" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
size="sm"
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap={4} wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</Box>
);
}
@@ -2,21 +2,26 @@ import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body"; import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
interface Props { interface Props {
pageId: string; pageId: string;
pageTitle?: string;
} }
export default function HistoryModal({ pageId }: Props) {
export default function HistoryModal({ pageId, pageTitle }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isModalOpen, setModalOpen] = useAtom(historyAtoms); const [isModalOpen, setModalOpen] = useAtom(historyAtoms);
const isMobile = useMediaQuery("(max-width: 800px)");
return ( if (isMobile) {
<> return (
<Modal.Root <Modal.Root
size={1200}
opened={isModalOpen} opened={isModalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
fullScreen
> >
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -28,11 +33,37 @@ export default function HistoryModal({ pageId }: Props) {
</Modal.Title> </Modal.Title>
<Modal.CloseButton /> <Modal.CloseButton />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body
<HistoryModalBody pageId={pageId} /> p={0}
style={{ height: "calc(100vh - 60px)", overflow: "hidden" }}
>
<HistoryModalMobile pageId={pageId} pageTitle={pageTitle} />
</Modal.Body> </Modal.Body>
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
</> );
}
return (
<Modal.Root
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
</Modal.Body>
</Modal.Content>
</Modal.Root>
); );
} }
@@ -1,29 +1,44 @@
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor"; import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
} from "@/features/page-history/atoms/history-atoms";
interface HistoryProps { function HistoryView() {
historyId: string;
}
function HistoryView({ historyId }: HistoryProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError } = usePageHistoryQuery(historyId); const historyId = useAtomValue(activeHistoryIdAtom);
const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom);
if (isLoading) { const {
data,
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
} = usePageHistoryQuery(prevHistoryId);
if (isLoadingCurrent || isLoadingPrev) {
return <></>; return <></>;
} }
if (isError || !data) { if (isErrorCurrent || !data) {
return <div>{t("Error fetching page data.")}</div>; return <div>{t("Error fetching page data.")}</div>;
} }
return ( return (
data && ( <div>
<div> <HistoryEditor
<HistoryEditor content={data.content} title={data.title} /> content={data.content}
</div> title={data.title}
) previousContent={!isErrorPrev ? prevData?.content : undefined}
/>
</div>
); );
} }
@@ -1,49 +0,0 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
}
.active {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
}
.sidebarMain {
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -0,0 +1,3 @@
export { useDiffNavigation } from "./use-diff-navigation";
export { useHistoryRestore } from "./use-history-restore";
export { useHistoryReset } from "./use-history-reset";
@@ -0,0 +1,58 @@
import { useAtomValue } from "jotai";
import { RefObject, useCallback, useEffect, useState } from "react";
import { diffCountsAtom } from "@/features/page-history/atoms/history-atoms";
/**
* Manages navigation between diff changes in the history view.
* Provides prev/next handlers and auto-scrolls to the current change.
*/
export function useDiffNavigation(
scrollViewportRef: RefObject<HTMLDivElement>,
) {
const diffCounts = useAtomValue(diffCountsAtom);
const [currentChangeIndex, setCurrentChangeIndex] = useState(0);
const scrollToChangeIndex = useCallback(
(index: number) => {
const viewport = scrollViewportRef.current;
if (!viewport || index < 1) return;
const element = viewport.querySelector(`[data-diff-index="${index}"]`);
if (element instanceof HTMLElement) {
const elementTop = element.offsetTop;
const viewportHeight = viewport.clientHeight;
const scrollTarget =
elementTop - viewportHeight / 2 + element.offsetHeight / 2;
viewport.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
},
[scrollViewportRef],
);
useEffect(() => {
if (diffCounts && diffCounts.total > 0) {
setCurrentChangeIndex(1);
requestAnimationFrame(() => scrollToChangeIndex(1));
} else {
setCurrentChangeIndex(0);
}
}, [diffCounts, scrollToChangeIndex]);
const handlePrevChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex <= 1 ? diffCounts.total : currentChangeIndex - 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
const handleNextChange = useCallback(() => {
if (!diffCounts || diffCounts.total === 0) return;
const newIndex =
currentChangeIndex >= diffCounts.total ? 1 : currentChangeIndex + 1;
setCurrentChangeIndex(newIndex);
scrollToChangeIndex(newIndex);
}, [diffCounts, currentChangeIndex, scrollToChangeIndex]);
return { currentChangeIndex, handlePrevChange, handleNextChange };
}
@@ -0,0 +1,24 @@
import { useAtom } from "jotai";
import { useEffect } from "react";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
} from "@/features/page-history/atoms/history-atoms";
/**
* Resets history state when pageId changes.
* Clears active selection and diff counts.
*/
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
useEffect(() => {
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
}, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
}
@@ -0,0 +1,78 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useParams } from "react-router-dom";
import {
activeHistoryIdAtom,
historyAtoms,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
export function useHistoryRestore() {
const { t } = useTranslation();
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId);
const mainEditor = useAtomValue(pageEditorAtom);
const mainEditorTitle = useAtomValue(titleEditorAtom);
const setHistoryModalOpen = useSetAtom(historyAtoms);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const canRestore = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
);
const handleRestore = useCallback(() => {
if (!activeHistoryData) return;
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.run();
mainEditor
.chain()
.clearContent()
.setContent(activeHistoryData.content)
.run();
setHistoryModalOpen(false);
notifications.show({ message: t("Successfully restored") });
}, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]);
const confirmRestore = useCallback(() => {
modals.openConfirmModal({
title: t("Please confirm your action"),
children: (
<Text size="sm">
{t(
"Are you sure you want to restore this version? Any changes not versioned will be lost.",
)}
</Text>
),
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleRestore,
});
}, [t, handleRestore]);
return { canRestore, confirmRestore };
}
@@ -1,19 +1,38 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import { import {
getPageHistoryById, getPageHistoryById,
getPageHistoryList, getPageHistoryList,
} from "@/features/page-history/services/page-history-service"; } from "@/features/page-history/services/page-history-service";
import { IPageHistory } from "@/features/page-history/types/page.types"; import { IPageHistory } from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main";
const HISTORY_STALE_TIME = 60 * 60 * 1000;
export function prefetchPageHistory(historyId: string) {
return queryClient.prefetchQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
staleTime: HISTORY_STALE_TIME,
});
}
export function usePageHistoryListQuery( export function usePageHistoryListQuery(
pageId: string, pageId: string,
): UseQueryResult<IPagination<IPageHistory>, Error> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPageHistory>, unknown>> {
return useQuery({ return useInfiniteQuery({
queryKey: ["page-history-list", pageId], queryKey: ["page-history-list", pageId],
queryFn: () => getPageHistoryList(pageId), queryFn: ({ pageParam }) => getPageHistoryList(pageId, pageParam),
enabled: !!pageId, enabled: !!pageId,
gcTime: 0, gcTime: 0,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -24,6 +43,6 @@ export function usePageHistoryQuery(
queryKey: ["page-history", historyId], queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId), queryFn: () => getPageHistoryById(historyId),
enabled: !!historyId, enabled: !!historyId,
staleTime: 10 * 60 * 1000, staleTime: HISTORY_STALE_TIME,
}); });
} }
@@ -4,9 +4,11 @@ import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList( export async function getPageHistoryList(
pageId: string, pageId: string,
cursor?: string,
): Promise<IPagination<IPageHistory>> { ): Promise<IPagination<IPageHistory>> {
const req = await api.post("/pages/history", { const req = await api.post("/pages/history", {
pageId, pageId,
cursor,
}); });
return req.data; return req.data;
} }
@@ -250,12 +250,10 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId, enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }), queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }),
initialPageParam: 1, initialPageParam: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -263,13 +261,11 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return getSidebarPages({ spaceId: data.spaceId, page: pageParam }); return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam });
}, },
initialPageParam: 1, initialPageParam: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined, lastPage.meta?.nextCursor ?? undefined,
}); });
} }
@@ -72,22 +72,19 @@ export async function getSidebarPages(
export async function getAllSidebarPages( export async function getAllSidebarPages(
params: SidebarPagesParams, params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> { ): Promise<InfiniteData<IPagination<IPage>, unknown>> {
let page = 1; let cursor: string | undefined = undefined;
let hasNextPage = false;
const pages: IPagination<IPage>[] = []; const pages: IPagination<IPage>[] = [];
const pageParams: number[] = []; const pageParams: (string | undefined)[] = [];
do { do {
const req = await api.post("/pages/sidebar-pages", { ...params, page: page }); const req = await api.post("/pages/sidebar-pages", { ...params, cursor });
const data: IPagination<IPage> = req.data; const data: IPagination<IPage> = req.data;
pages.push(data); pages.push(data);
pageParams.push(page); pageParams.push(cursor);
hasNextPage = data.meta.hasNextPage; cursor = data.meta.nextCursor ?? undefined;
} while (cursor);
page += 1;
} while (hasNextPage);
return { return {
pageParams, pageParams,
@@ -30,15 +30,15 @@ import { useState } from "react";
import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal"; import TrashPageContentModal from "@/features/page/trash/components/trash-page-content-modal";
import { UserInfo } from "@/components/common/user-info.tsx"; import { UserInfo } from "@/components/common/user-info.tsx";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function Trash() { export default function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { page, setPage } = usePaginateAndSearch(); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug); const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, { const { data: deletedPages, isLoading } = useDeletedPagesQuery(space?.id, {
page, limit: 50 cursor, limit: 50
}); });
const restorePageMutation = useRestorePageMutation(); const restorePageMutation = useRestorePageMutation();
const deletePageMutation = useDeletePageMutation(); const deletePageMutation = useDeletePageMutation();
@@ -206,10 +206,10 @@ export default function Trash() {
{deletedPages && deletedPages.items.length > 0 && ( {deletedPages && deletedPages.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={deletedPages.meta?.hasPrevPage}
hasPrevPage={deletedPages.meta.hasPrevPage} hasNextPage={deletedPages.meta?.hasNextPage}
hasNextPage={deletedPages.meta.hasNextPage} onNext={() => goNext(deletedPages.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</Stack> </Stack>
@@ -62,7 +62,7 @@ export interface ICopyPageToSpace {
export interface SidebarPagesParams { export interface SidebarPagesParams {
spaceId?: string; spaceId?: string;
pageId?: string; pageId?: string;
page?: number; // pagination cursor?: string;
} }
export interface IPageInput { export interface IPageInput {
@@ -18,7 +18,6 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -26,7 +25,7 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
@@ -53,7 +52,6 @@ export function SearchSpotlightFilters({
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1,
limit: 100, limit: 100,
query: debouncedSpaceQuery, query: debouncedSpaceQuery,
}); });
@@ -265,7 +263,9 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={option.disabled || (isAiMode && option.value === "attachment")} disabled={
option.disabled || (isAiMode && option.value === "attachment")
}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -275,11 +275,13 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && ( {!option.disabled &&
<Text size="xs" mt={4}> isAiMode &&
{t("Ask AI not available for attachments")} option.value === "attachment" && (
</Text> <Text size="xs" mt={4}>
)} {t("Ask AI not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -10,8 +10,8 @@ import {
export async function searchPage( export async function searchPage(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search", params); const req = await api.post<{ items: IPageSearch[] }>("/search", params);
return req.data; return req.data.items;
} }
export async function searchSuggestions( export async function searchSuggestions(
@@ -24,13 +24,13 @@ export async function searchSuggestions(
export async function searchShare( export async function searchShare(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IPageSearch[]> { ): Promise<IPageSearch[]> {
const req = await api.post<IPageSearch[]>("/search/share-search", params); const req = await api.post<{ items: IPageSearch[] }>("/search/share-search", params);
return req.data; return req.data.items;
} }
export async function searchAttachments( export async function searchAttachments(
params: IPageSearchParams, params: IPageSearchParams,
): Promise<IAttachmentSearch[]> { ): Promise<IAttachmentSearch[]> {
const req = await api.post<IAttachmentSearch[]>("/search-attachments", params); const req = await api.post<{ items: IAttachmentSearch[] }>("/search-attachments", params);
return req.data; return req.data.items;
} }
@@ -1,8 +1,9 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import React, { useState } from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts";
import { ISharedItem } from "@/features/share/types/share.types.ts"; import { ISharedItem } from "@/features/share/types/share.types.ts";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -14,8 +15,8 @@ import classes from "./share.module.css";
export default function ShareList() { export default function ShareList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSharesQuery({ page }); const { data, isLoading } = useGetSharesQuery({ cursor });
return ( return (
<> <>
@@ -86,10 +87,10 @@ export default function ShareList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -33,7 +33,7 @@ export function useGetSharesQuery(
params?: QueryParams, params?: QueryParams,
): UseQueryResult<IPagination<ISharedItem>, Error> { ): UseQueryResult<IPagination<ISharedItem>, Error> {
return useQuery({ return useQuery({
queryKey: ["share-list"], queryKey: ["share-list", params],
queryFn: () => getShares(params), queryFn: () => getShares(params),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -15,7 +15,7 @@ import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts
export default function SpaceGrid() { export default function SpaceGrid() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 10 }); const { data, isLoading } = useGetSpacesQuery({ limit: 10 });
const cards = data?.items.slice(0, 9).map((space, index) => ( const cards = data?.items.slice(0, 9).map((space, index) => (
<Card <Card
@@ -1,5 +1,6 @@
import { Group, Table, Text } from "@mantine/core"; import { Group, Table, Text } from "@mantine/core";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -12,8 +13,8 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGetSpacesQuery({ page }); const { data, isLoading } = useGetSpacesQuery({ cursor });
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -72,10 +73,10 @@ export default function SpaceList() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
@@ -41,9 +41,9 @@ export default function SpaceMembersList({
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { search, page, setPage, handleSearch } = usePaginateAndSearch(); const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useSpaceMembersQuery(spaceId, { const { data, isLoading } = useSpaceMembersQuery(spaceId, {
page, cursor,
limit: 100, limit: 100,
query: search, query: search,
}); });
@@ -206,10 +206,10 @@ export default function SpaceMembersList({
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -28,19 +28,19 @@ import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps { interface AllSpacesListProps {
spaces: any[]; spaces: any[];
onSearch: (query: string) => void; onSearch: (query: string) => void;
page: number;
hasPrevPage?: boolean; hasPrevPage?: boolean;
hasNextPage?: boolean; hasNextPage?: boolean;
onPageChange: (page: number) => void; onNext: () => void;
onPrev: () => void;
} }
export default function AllSpacesList({ export default function AllSpacesList({
spaces, spaces,
onSearch, onSearch,
page,
hasPrevPage, hasPrevPage,
hasNextPage, hasNextPage,
onPageChange, onNext,
onPrev,
}: AllSpacesListProps) { }: AllSpacesListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [settingsOpened, { open: openSettings, close: closeSettings }] = const [settingsOpened, { open: openSettings, close: closeSettings }] =
@@ -145,10 +145,10 @@ export default function AllSpacesList({
{spaces.length > 0 && ( {spaces.length > 0 && (
<Paginate <Paginate
currentPage={page}
hasPrevPage={hasPrevPage} hasPrevPage={hasPrevPage}
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
onPageChange={onPageChange} onNext={onNext}
onPrev={onPrev}
/> />
)} )}
@@ -1,6 +1,6 @@
import { Group, Table, Avatar, Text, Alert } from "@mantine/core"; import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React, { useState } from "react"; import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts"; import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx"; import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
@@ -8,12 +8,13 @@ import { timeAgo } from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function WorkspaceInvitesTable() { export default function WorkspaceInvitesTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useWorkspaceInvitationsQuery({ const { data, isLoading } = useWorkspaceInvitationsQuery({
page, cursor,
limit: 100, limit: 100,
}); });
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -65,10 +66,10 @@ export default function WorkspaceInvitesTable() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -21,9 +21,9 @@ import MemberActionMenu from "@/features/workspace/components/members/components
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const { search, page, setPage, handleSearch } = usePaginateAndSearch(); const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useWorkspaceMembersQuery({ const { data, isLoading } = useWorkspaceMembersQuery({
page, cursor,
limit: 100, limit: 100,
query: search, query: search,
}); });
@@ -111,10 +111,10 @@ export default function WorkspaceMembersTable() {
{data?.items.length > 0 && ( {data?.items.length > 0 && (
<Paginate <Paginate
currentPage={page} hasPrevPage={data?.meta?.hasPrevPage}
hasPrevPage={data?.meta.hasPrevPage} hasNextPage={data?.meta?.hasNextPage}
hasNextPage={data?.meta.hasNextPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPageChange={setPage} onPrev={goPrev}
/> />
)} )}
</> </>
@@ -0,0 +1,28 @@
import { useState, useCallback } from "react";
export function useCursorPaginate() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const goNext = useCallback((nextCursor: string | null | undefined) => {
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
const resetCursor = useCallback(() => {
setCursor(undefined);
setCursorStack([]);
}, []);
return { cursor, goNext, goPrev, resetCursor };
}
@@ -2,16 +2,33 @@ import { useState, useRef, useCallback } from "react";
export function usePaginateAndSearch(initialQuery: string = "") { export function usePaginateAndSearch(initialQuery: string = "") {
const [search, setSearch] = useState(initialQuery); const [search, setSearch] = useState(initialQuery);
const [page, setPage] = useState(1); const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([]);
const prevSearchRef = useRef(search); const prevSearchRef = useRef(search);
const handleSearch = useCallback((newQuery: string) => { const handleSearch = useCallback((newQuery: string) => {
if (prevSearchRef.current !== newQuery) { if (prevSearchRef.current !== newQuery) {
prevSearchRef.current = newQuery; prevSearchRef.current = newQuery;
setSearch(newQuery); setSearch(newQuery);
setPage(1); setCursor(undefined);
setCursorStack([]);
} }
}, []); }, []);
return { search, page, setPage, handleSearch }; const goNext = useCallback((nextCursor: string | null | undefined) => {
if (nextCursor) {
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
}, [cursor]);
const goPrev = useCallback(() => {
setCursorStack((prev) => {
const next = prev.slice(0, -1);
setCursor(prev[prev.length - 1]);
return next;
});
}, []);
return { search, cursor, goNext, goPrev, handleSearch };
} }
+4 -2
View File
@@ -1,6 +1,7 @@
export interface QueryParams { export interface QueryParams {
query?: string; query?: string;
page?: number; cursor?: string;
beforeCursor?: string;
limit?: number; limit?: number;
adminView?: boolean; adminView?: boolean;
} }
@@ -29,9 +30,10 @@ export interface ApiResponse<T> {
export type IPaginationMeta = { export type IPaginationMeta = {
limit: number; limit: number;
page: number;
hasNextPage: boolean; hasNextPage: boolean;
hasPrevPage: boolean; hasPrevPage: boolean;
nextCursor: string | null;
prevCursor: string | null;
}; };
export type IPagination<T> = { export type IPagination<T> = {
items: T[]; items: T[];
+4 -4
View File
@@ -11,10 +11,10 @@ import useUserRole from "@/hooks/use-user-role";
export default function Spaces() { export default function Spaces() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { search, page, setPage, handleSearch } = usePaginateAndSearch(); const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useGetSpacesQuery({ const { data, isLoading } = useGetSpacesQuery({
page, cursor,
limit: 30, limit: 30,
query: search, query: search,
}); });
@@ -41,10 +41,10 @@ export default function Spaces() {
<AllSpacesList <AllSpacesList
spaces={data?.items || []} spaces={data?.items || []}
onSearch={handleSearch} onSearch={handleSearch}
page={page}
hasPrevPage={data?.meta?.hasPrevPage} hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage} hasNextPage={data?.meta?.hasNextPage}
onPageChange={setPage} onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/> />
</Box> </Box>
</Container> </Container>
+11 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.24.1", "version": "0.25.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -33,28 +33,28 @@
"@ai-sdk/google": "^3.0.9", "@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11", "@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12", "@ai-sdk/openai-compatible": "^2.0.12",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.982.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.3.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^8.3.0", "@fastify/static": "^9.0.0",
"@langchain/core": "1.1.13", "@langchain/core": "1.1.18",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11", "@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11", "@nestjs/core": "^11.1.13",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.11", "@nestjs/platform-fastify": "^11.1.13",
"@nestjs/platform-socket.io": "^11.1.11", "@nestjs/platform-socket.io": "^11.1.13",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.11", "@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
@@ -79,6 +79,8 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents); this.customEvents = (customEvents as any) ?? ({} as any as CustomEvents);
this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`); this.sub.subscribe(this.msgChannel, `${this.msgChannel}:${this.serverId}`);
this.sub.on('messageBuffer', this.handleRedisMessage); this.sub.on('messageBuffer', this.handleRedisMessage);
this.pub.on('error', () => {});
this.sub.on('error', () => {});
} }
private getKey(documentName: string) { private getKey(documentName: string) {
return `${this.lockPrefix}:${documentName}`; return `${this.lockPrefix}:${documentName}`;
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class UpdatedPageEvent { export class UpdatedPageEvent {
page: Page; page: Page;
@@ -12,7 +13,10 @@ export class UpdatedPageEvent {
export class HistoryListener { export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name); private readonly logger = new Logger(HistoryListener.name);
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {} constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly environmentService: EnvironmentService,
) {}
@OnEvent('collab.page.updated') @OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) { async handleCreatePageHistory(event: UpdatedPageEvent) {
@@ -20,13 +24,17 @@ export class HistoryListener {
const pageCreationTime = new Date(page.createdAt).getTime(); const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now(); const currentTime = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000; const FIVE_MINUTES = this.environmentService.isDevelopment()
? 60 * 1000
: 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) { if (currentTime - pageCreationTime < FIVE_MINUTES) {
return; return;
} }
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id); const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
includeContent: true,
});
if ( if (
!lastHistory || !lastHistory ||
@@ -19,7 +19,8 @@ async function bootstrap() {
}, },
}), }),
{ {
bufferLogs: true, logger: false,
bufferLogs: false,
}, },
); );
@@ -36,7 +37,8 @@ async function bootstrap() {
const logger = new Logger('CollabServer'); const logger = new Logger('CollabServer');
const port = process.env.COLLAB_PORT || 3001; const port = process.env.COLLAB_PORT || 3001;
await app.listen(port, '0.0.0.0', () => { const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host, () => {
logger.log(`Listening on http://127.0.0.1:${port}`); logger.log(`Listening on http://127.0.0.1:${port}`);
}); });
} }
@@ -9,16 +9,14 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types'; import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class CommentService { export class CommentService {
constructor( constructor(
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
@@ -68,14 +66,14 @@ export class CommentService {
async findByPageId( async findByPageId(
pageId: string, pageId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Comment>> { ): Promise<CursorPaginationResult<Comment>> {
const page = await this.pageRepo.findById(pageId); const page = await this.pageRepo.findById(pageId);
if (!page) { if (!page) {
throw new BadRequestException('Page not found'); throw new BadRequestException('Page not found');
} }
return await this.commentRepo.findPageComments(pageId, pagination); return this.commentRepo.findPageComments(pageId, pagination);
} }
async update( async update(
@@ -11,7 +11,7 @@ import { UpdateGroupDto } from '../dto/update-group.dto';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service'; import { GroupUserService } from './group-user.service';
@Injectable() @Injectable()
@@ -132,12 +132,8 @@ export class GroupService {
async getWorkspaceGroups( async getWorkspaceGroups(
workspaceId: string, workspaceId: string,
paginationOptions: PaginationOptions, paginationOptions: PaginationOptions,
): Promise<PaginationResult<Group>> { ): Promise<CursorPaginationResult<Group>> {
const groups = await this.groupRepo.getGroupsPaginated( return this.groupRepo.getGroupsPaginated(workspaceId, paginationOptions);
workspaceId,
paginationOptions,
);
return groups;
} }
async deleteGroup(groupId: string, workspaceId: string): Promise<void> { async deleteGroup(groupId: string, workspaceId: string): Promise<void> {
@@ -215,7 +215,6 @@ export class PageController {
} }
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
@@ -2,25 +2,25 @@ import { Injectable } from '@nestjs/common';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageHistory } from '@docmost/db/types/entity.types'; import { PageHistory } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class PageHistoryService { export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {} constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> { async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId); return await this.pageHistoryRepo.findById(historyId, {
includeContent: true,
});
} }
async findHistoryByPageId( async findHistoryByPageId(
pageId: string, pageId: string,
paginationOptions: PaginationOptions, paginationOptions: PaginationOptions,
): Promise<PaginationResult<any>> { ): Promise<CursorPaginationResult<PageHistory>> {
const pageHistory = await this.pageHistoryRepo.findPageHistoryByPageId( return this.pageHistoryRepo.findPageHistoryByPageId(
pageId, pageId,
paginationOptions, paginationOptions,
); );
return pageHistory;
} }
} }
@@ -10,9 +10,9 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { import {
executeWithPagination, CursorPaginationResult,
PaginationResult, executeWithCursorPagination,
} from '@docmost/db/pagination/pagination'; } from '@docmost/db/pagination/cursor-pagination';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
@@ -180,7 +180,7 @@ export class PageService {
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
pageId?: string, pageId?: string,
): Promise<any> { ): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
let query = this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
.select([ .select([
@@ -195,7 +195,6 @@ export class PageService {
'deletedAt', 'deletedAt',
]) ])
.select((eb) => this.pageRepo.withHasChildren(eb)) .select((eb) => this.pageRepo.withHasChildren(eb))
.orderBy('position', (ob) => ob.collate('C').asc())
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('spaceId', '=', spaceId); .where('spaceId', '=', spaceId);
@@ -205,12 +204,19 @@ export class PageService {
query = query.where('parentPageId', 'is', null); query = query.where('parentPageId', 'is', null);
} }
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: 250, perPage: 250,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
position: cursor.position,
id: cursor.id,
}),
}); });
return result;
} }
async movePageToSpace(rootPage: Page, spaceId: string) { async movePageToSpace(rootPage: Page, spaceId: string) {
@@ -259,7 +265,7 @@ export class PageService {
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds, pageId: pageIds,
workspaceId: rootPage.workspaceId workspaceId: rootPage.workspaceId,
}); });
} }
}); });
@@ -387,9 +393,14 @@ export class PageService {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: page.id === rootPage.id parentPageId:
? (isDuplicateInSameSpace ? rootPage.parentPageId : null) page.id === rootPage.id
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null), ? isDuplicateInSameSpace
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
}; };
}), }),
); );
@@ -569,22 +580,22 @@ export class PageService {
async getRecentSpacePages( async getRecentSpacePages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<CursorPaginationResult<Page>> {
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination); return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
} }
async getRecentPages( async getRecentPages(
userId: string, userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<CursorPaginationResult<Page>> {
return await this.pageRepo.getRecentPages(userId, pagination); return this.pageRepo.getRecentPages(userId, pagination);
} }
async getDeletedSpacePages( async getDeletedSpacePages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<CursorPaginationResult<Page>> {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
} }
async forceDelete(pageId: string, workspaceId: string): Promise<void> { async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -26,11 +26,11 @@ export class SearchService {
userId?: string; userId?: string;
workspaceId: string; workspaceId: string;
}, },
): Promise<SearchResponseDto[]> { ): Promise<{ items: SearchResponseDto[] }> {
const { query } = searchParams; const { query } = searchParams;
if (query.length < 1) { if (query.length < 1) {
return; return { items: [] };
} }
const searchQuery = tsquery(query.trim() + '*'); const searchQuery = tsquery(query.trim() + '*');
@@ -62,7 +62,7 @@ export class SearchService {
) )
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit | 25) .limit(searchParams.limit || 25)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
if (!searchParams.shareId) { if (!searchParams.shareId) {
@@ -86,7 +86,7 @@ export class SearchService {
const shareId = searchParams.shareId; const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId); const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) { if (!share || share.workspaceId !== opts.workspaceId) {
return []; return { items: [] };
} }
const pageIdsToSearch = []; const pageIdsToSearch = [];
@@ -108,10 +108,10 @@ export class SearchService {
.where('id', 'in', pageIdsToSearch) .where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId); .where('workspaceId', '=', opts.workspaceId);
} else { } else {
return []; return { items: [] };
} }
} else { } else {
return []; return { items: [] };
} }
//@ts-ignore //@ts-ignore
@@ -127,7 +127,7 @@ export class SearchService {
return result; return result;
}); });
return searchResults; return { items: searchResults };
} }
async searchSuggestions( async searchSuggestions(
@@ -13,7 +13,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto'; import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission'; import { SpaceRole } from '../../../common/helpers/types/permission';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class SpaceMemberService { export class SpaceMemberService {
@@ -68,18 +68,16 @@ export class SpaceMemberService {
spaceId: string, spaceId: string,
workspaceId: string, workspaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
) { ): Promise<CursorPaginationResult<any>> {
const space = await this.spaceRepo.findById(spaceId, workspaceId); const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) { if (!space) {
throw new NotFoundException('Space not found'); throw new NotFoundException('Space not found');
} }
const members = await this.spaceMemberRepo.getSpaceMembersPaginated( return await this.spaceMemberRepo.getSpaceMembersPaginated(
spaceId, spaceId,
pagination, pagination,
); );
return members;
} }
async addMembersToSpaceBatch( async addMembersToSpaceBatch(
@@ -276,7 +274,7 @@ export class SpaceMemberService {
async getUserSpaces( async getUserSpaces(
userId: string, userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Space>> { ): Promise<CursorPaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination); return this.spaceMemberRepo.getUserSpaces(userId, pagination);
} }
} }
@@ -8,7 +8,6 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space, User } from '@docmost/db/types/entity.types'; import { Space, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto'; import { UpdateSpaceDto } from '../dto/update-space.dto';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
@@ -17,6 +16,7 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {
@@ -130,13 +130,8 @@ export class SpaceService {
async getWorkspaceSpaces( async getWorkspaceSpaces(
workspaceId: string, workspaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Space>> { ): Promise<CursorPaginationResult<Space>> {
const spaces = await this.spaceRepo.getSpacesInWorkspace( return this.spaceRepo.getSpacesInWorkspace(workspaceId, pagination);
workspaceId,
pagination,
);
return spaces;
} }
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> { async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
@@ -23,7 +23,7 @@ import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-ac
import { TokenService } from '../../auth/services/token.service'; import { TokenService } from '../../auth/services/token.service';
import { nanoIdGen } from '../../../common/helpers'; import { nanoIdGen } from '../../../common/helpers';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { DomainService } from 'src/integrations/environment/domain.service'; import { DomainService } from 'src/integrations/environment/domain.service';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -64,12 +64,13 @@ export class WorkspaceInvitationService {
); );
} }
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
return result;
} }
async getInvitationById(invitationId: string, workspace: Workspace) { async getInvitationById(invitationId: string, workspace: Workspace) {
@@ -19,7 +19,6 @@ import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto'; import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
@@ -28,12 +27,12 @@ import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { AttachmentType } from 'src/core/attachment/attachment.constants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -376,13 +375,8 @@ export class WorkspaceService {
async getWorkspaceUsers( async getWorkspaceUsers(
workspaceId: string, workspaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<User>> { ): Promise<CursorPaginationResult<User>> {
const users = await this.userRepo.getUsersPaginated( return this.userRepo.getUsersPaginated(workspaceId, pagination);
workspaceId,
pagination,
);
return users;
} }
async updateWorkspaceUserRole( async updateWorkspaceUserRole(
@@ -0,0 +1,348 @@
// adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/cursor.ts - MIT
import {
OrderByDirection,
OrderByModifiers,
ReferenceExpression,
SelectQueryBuilder,
StringReference,
} from 'kysely';
type SortField<DB, TB extends keyof DB, O> =
| {
expression:
| (StringReference<DB, TB> & keyof O & string)
| (StringReference<DB, TB> & `${string}.${keyof O & string}`);
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
key?: keyof O & string;
}
| {
expression: ReferenceExpression<DB, TB>;
direction: OrderByDirection;
orderModifier?: OrderByModifiers;
key: keyof O & string;
};
type ExtractSortFieldKey<
DB,
TB extends keyof DB,
O,
T extends SortField<DB, TB, O>,
> = T['key'] extends keyof O & string
? T['key']
: T['expression'] extends keyof O & string
? T['expression']
: T['expression'] extends `${string}.${infer K}`
? K extends keyof O & string
? K
: never
: never;
type Fields<DB, TB extends keyof DB, O> = ReadonlyArray<
Readonly<SortField<DB, TB, O>>
>;
type FieldNames<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
[TIndex in keyof T]: ExtractSortFieldKey<DB, TB, O, T[TIndex]>;
};
type EncodeCursorValues<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = {
[TIndex in keyof T]: [
ExtractSortFieldKey<DB, TB, O, T[TIndex]>,
O[ExtractSortFieldKey<DB, TB, O, T[TIndex]>],
];
};
export type CursorEncoder<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (values: EncodeCursorValues<DB, TB, O, T>) => string;
type DecodedCursor<DB, TB extends keyof DB, O, T extends Fields<DB, TB, O>> = {
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: string;
};
export type CursorDecoder<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (
cursor: string,
fields: FieldNames<DB, TB, O, T>,
) => DecodedCursor<DB, TB, O, T>;
type ParsedCursorValues<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = {
[TField in ExtractSortFieldKey<DB, TB, O, T[number]>]: O[TField];
};
export type CursorParser<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
> = (cursor: DecodedCursor<DB, TB, O, T>) => ParsedCursorValues<DB, TB, O, T>;
type CursorPaginationResultRow<
TRow,
TCursorKey extends string | boolean | undefined,
> = TRow & {
[K in TCursorKey extends undefined
? never
: TCursorKey extends false
? never
: TCursorKey extends true
? '$cursor'
: TCursorKey]: string;
};
type CursorPaginationMeta = {
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
nextCursor: string | null;
prevCursor: string | null;
};
export type CursorPaginationResult<
TRow,
TCursorKey extends string | boolean | undefined = undefined,
> = {
meta: CursorPaginationMeta;
items: CursorPaginationResultRow<TRow, TCursorKey>[];
};
export async function executeWithCursorPagination<
DB,
TB extends keyof DB,
O,
const TFields extends Fields<DB, TB, O>,
TCursorKey extends string | boolean | undefined = undefined,
>(
qb: SelectQueryBuilder<DB, TB, O>,
opts: {
perPage: number;
cursor?: string;
beforeCursor?: string;
cursorPerRow?: TCursorKey;
fields: TFields;
encodeCursor?: CursorEncoder<DB, TB, O, TFields>;
decodeCursor?: CursorDecoder<DB, TB, O, TFields>;
parseCursor:
| CursorParser<DB, TB, O, TFields>
| { parse: CursorParser<DB, TB, O, TFields> };
},
): Promise<CursorPaginationResult<O, TCursorKey>> {
const encodeCursor = opts.encodeCursor ?? defaultEncodeCursor;
const decodeCursor = opts.decodeCursor ?? defaultDecodeCursor;
const parseCursor =
typeof opts.parseCursor === 'function'
? opts.parseCursor
: opts.parseCursor.parse;
const fields = opts.fields.map((field) => {
let key = field.key;
if (!key && typeof field.expression === 'string') {
const expressionParts = field.expression.split('.');
key = (expressionParts[1] ?? expressionParts[0]) as
| (keyof O & string)
| undefined;
}
if (!key) throw new Error('missing key');
return { ...field, key };
});
function generateCursor(row: O): string {
const cursorFieldValues = fields.map(({ key }) => [
key,
row[key],
]) as EncodeCursorValues<DB, TB, O, TFields>;
return encodeCursor(cursorFieldValues);
}
const fieldNames = fields.map((field) => field.key) as FieldNames<
DB,
TB,
O,
TFields
>;
function applyCursor(
qb: SelectQueryBuilder<DB, TB, O>,
encoded: string,
defaultDirection: OrderByDirection,
) {
const decoded = decodeCursor(encoded, fieldNames);
const cursor = parseCursor(decoded);
return qb.where(({ and, or, eb }) => {
let expression;
for (let i = fields.length - 1; i >= 0; --i) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const field = fields[i]!;
const comparison = field.direction === defaultDirection ? '>' : '<';
const value = cursor[field.key as keyof typeof cursor];
const conditions = [eb(field.expression, comparison, value)];
if (expression) {
conditions.push(and([eb(field.expression, '=', value), expression]));
}
expression = or(conditions);
}
if (!expression) {
throw new Error('Error building cursor expression');
}
return expression;
});
}
if (opts.cursor) qb = applyCursor(qb, opts.cursor, 'asc');
if (opts.beforeCursor) qb = applyCursor(qb, opts.beforeCursor, 'desc');
const reversed = !!opts.beforeCursor && !opts.cursor;
for (const { expression, direction, orderModifier } of fields) {
qb = qb.orderBy(
expression,
orderModifier ??
(reversed ? (direction === 'asc' ? 'desc' : 'asc') : direction),
);
}
const rows = await qb.limit(opts.perPage + 1).execute();
const hasNextPage = rows.length > opts.perPage;
// If we fetched an extra row to determine if we have a next page, that
// shouldn't be in the returned results
if (rows.length > opts.perPage) rows.pop();
if (reversed) rows.reverse();
const startRow = rows[0];
const endRow = rows[rows.length - 1];
const hasPrevPage = !!opts.cursor;
const prevCursor = hasPrevPage && startRow ? generateCursor(startRow) : null;
const nextCursor = hasNextPage && endRow ? generateCursor(endRow) : null;
return {
items: rows.map((row) => {
if (opts.cursorPerRow) {
const cursorKey =
typeof opts.cursorPerRow === 'string' ? opts.cursorPerRow : '$cursor';
(row as any)[cursorKey] = generateCursor(row);
}
return row as CursorPaginationResultRow<O, TCursorKey>;
}),
meta: {
limit: opts.perPage,
hasNextPage,
hasPrevPage,
nextCursor,
prevCursor,
},
};
}
export function defaultEncodeCursor<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
>(values: EncodeCursorValues<DB, TB, O, T>) {
const cursor = new URLSearchParams();
for (const [key, value] of values) {
switch (typeof value) {
case 'string':
cursor.set(key, value);
break;
case 'number':
case 'bigint':
cursor.set(key, value.toString(10));
break;
case 'object': {
if (value instanceof Date) {
cursor.set(key, value.toISOString());
break;
}
}
// eslint-disable-next-line no-fallthrough
default:
throw new Error(`Unable to encode '${key.toString()}'`);
}
}
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
O,
T extends Fields<DB, TB, O>,
>(
cursor: string,
fields: FieldNames<DB, TB, O, T>,
): DecodedCursor<DB, TB, O, T> {
let parsed;
try {
parsed = [
...new URLSearchParams(
Buffer.from(cursor, 'base64url').toString('utf8'),
).entries(),
];
} catch {
throw new Error('Unparsable cursor');
}
if (parsed.length !== fields.length) {
throw new Error('Unexpected number of fields');
}
for (let i = 0; i < fields.length; i++) {
const field = parsed[i];
const expectedName = fields[i];
if (!field) {
throw new Error('Unable to find field');
}
if (field[0] !== expectedName) {
throw new Error('Unexpected field name');
}
}
return Object.fromEntries(parsed) as DecodedCursor<DB, TB, O, T>;
}
@@ -9,11 +9,6 @@ import {
} from 'class-validator'; } from 'class-validator';
export class PaginationOptions { export class PaginationOptions {
@IsOptional()
@IsNumber()
@Min(1)
page = 1;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
@@ -21,6 +16,14 @@ export class PaginationOptions {
@Max(100) @Max(100)
limit = 20; limit = 20;
@IsOptional()
@IsString()
cursor?: string;
@IsOptional()
@IsString()
beforeCursor?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
query: string; query: string;
@@ -8,7 +8,7 @@ import {
UpdatableComment, UpdatableComment,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
@@ -37,15 +37,15 @@ export class CommentRepo {
.selectAll('comments') .selectAll('comments')
.select((eb) => this.withCreator(eb)) .select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb)) .select((eb) => this.withResolvedBy(eb))
.where('pageId', '=', pageId) .where('pageId', '=', pageId);
.orderBy('createdAt', 'asc');
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
return result;
} }
async updateComment( async updateComment(
@@ -9,7 +9,7 @@ import { dbOrTx, executeTx } from '@docmost/db/utils';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types'; import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -52,8 +52,7 @@ export class GroupUserRepo {
.selectFrom('groupUsers') .selectFrom('groupUsers')
.innerJoin('users', 'users.id', 'groupUsers.userId') .innerJoin('users', 'users.id', 'groupUsers.userId')
.selectAll('users') .selectAll('users')
.where('groupId', '=', groupId) .where('groupId', '=', groupId);
.orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
@@ -61,9 +60,12 @@ export class GroupUserRepo {
); );
} }
const result = await executeWithPagination(query, { const result = await executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'users.id', direction: 'asc', key: 'id' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
result.items.map((user) => { result.items.map((user) => {
@@ -10,8 +10,8 @@ import {
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto'; import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable() @Injectable()
export class GroupRepo { export class GroupRepo {
@@ -104,17 +104,19 @@ export class GroupRepo {
} }
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db let baseQuery = this.db
.selectFrom('groups') .selectFrom('groups')
.selectAll('groups') .selectAll('groups')
.select((eb) => this.withMemberCount(eb)) .select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId);
.orderBy('memberCount', 'desc')
.orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => baseQuery = baseQuery.where((eb) =>
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`, sql`f_unaccent(description)`,
'ilike', 'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`, sql`f_unaccent(${'%' + pagination.query + '%'})`,
@@ -122,12 +124,24 @@ export class GroupRepo {
); );
} }
const result = executeWithPagination(query, { const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
page: pagination.page, return executeWithCursorPagination(query, {
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{
expression: 'sub.memberCount',
direction: 'desc',
key: 'memberCount',
},
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
memberCount: parseInt(cursor.memberCount, 10),
id: cursor.id,
}),
}); });
return result;
} }
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) { withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
@@ -8,7 +8,7 @@ import {
PageHistory, PageHistory,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
@@ -17,15 +17,32 @@ import { DB } from '@docmost/db/types/db';
export class PageHistoryRepo { export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'spaceId',
'workspaceId',
'createdAt',
];
async findById( async findById(
pageHistoryId: string, pageHistoryId: string,
trx?: KyselyTransaction, opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> { ): Promise<PageHistory> {
const db = dbOrTx(this.db, trx); const db = dbOrTx(this.db, opts?.trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.where('id', '=', pageHistoryId) .where('id', '=', pageHistoryId)
.executeTakeFirst(); .executeTakeFirst();
@@ -63,25 +80,32 @@ export class PageHistoryRepo {
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) { async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb)) .select((eb) => this.withLastUpdatedBy(eb))
.where('pageId', '=', pageId) .where('pageId', '=', pageId);
.orderBy('createdAt', 'desc');
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
return result;
} }
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) { async findPageLastHistory(
const db = dbOrTx(this.db, trx); pageId: string,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db return await db
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId) .where('pageId', '=', pageId)
.limit(1) .limit(1)
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
@@ -8,7 +8,7 @@ import {
UpdatablePage, UpdatablePage,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
@@ -281,15 +281,21 @@ export class PageRepo {
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withSpace(eb)) .select((eb) => this.withSpace(eb))
.where('spaceId', '=', spaceId) .where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null);
.orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
}); });
return result;
} }
async getRecentPages(userId: string, pagination: PaginationOptions) { async getRecentPages(userId: string, pagination: PaginationOptions) {
@@ -298,12 +304,20 @@ export class PageRepo {
.select(this.baseFields) .select(this.baseFields)
.select((eb) => this.withSpace(eb)) .select((eb) => this.withSpace(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null);
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
}); });
} }
@@ -331,15 +345,21 @@ export class PageRepo {
), ),
), ),
]), ]),
) );
.orderBy('deletedAt', 'desc');
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'deletedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
deletedAt: new Date(cursor.deletedAt),
id: cursor.id,
}),
}); });
return result;
} }
withSpace(eb: ExpressionBuilder<DB, 'pages'>) { withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
@@ -8,7 +8,7 @@ import {
UpdatableShare, UpdatableShare,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
@@ -143,12 +143,20 @@ export class ShareRepo {
.select((eb) => this.withPage(eb)) .select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId)) .select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb)) .select((eb) => this.withCreator(eb))
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId));
.orderBy('updatedAt', 'desc');
return executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
}); });
} }
@@ -10,7 +10,7 @@ import {
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo, UserSpaceRole } from './types'; import { MemberInfo, UserSpaceRole } from './types';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@@ -98,7 +98,7 @@ export class SpaceMemberRepo {
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
) { ) {
let query = this.db let baseQuery = this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.leftJoin('users', 'users.id', 'spaceMembers.userId') .leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId') .leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
@@ -114,12 +114,11 @@ export class SpaceMemberRepo {
'spaceMembers.createdAt', 'spaceMembers.createdAt',
]) ])
.select((eb) => this.groupRepo.withMemberCount(eb)) .select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId) .select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc') .where('spaceId', '=', spaceId);
.orderBy('spaceMembers.createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => baseQuery = baseQuery.where((eb) =>
eb( eb(
sql`f_unaccent(users.name)`, sql`f_unaccent(users.name)`,
'ilike', 'ilike',
@@ -138,9 +137,20 @@ export class SpaceMemberRepo {
); );
} }
const result = await executeWithPagination(query, { const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
page: pagination.page,
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
{ expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' },
],
parseCursor: (cursor) => ({
isGroup: parseInt(cursor.isGroup, 10),
createdAt: new Date(cursor.createdAt),
}),
}); });
let memberInfo: MemberInfo; let memberInfo: MemberInfo;
@@ -235,8 +245,7 @@ export class SpaceMemberRepo {
.selectFrom('spaces') .selectFrom('spaces')
.selectAll() .selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)]) .select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('id', 'in', this.getUserSpaceIdsQuery(userId)) .where('id', 'in', this.getUserSpaceIdsQuery(userId));
.orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
@@ -252,9 +261,12 @@ export class SpaceMemberRepo {
); );
} }
return executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
} }
} }
@@ -9,7 +9,7 @@ import {
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -110,8 +110,7 @@ export class SpaceRepo {
.selectFrom('spaces') .selectFrom('spaces')
.selectAll('spaces') .selectAll('spaces')
.select((eb) => [this.withMemberCount(eb)]) .select((eb) => [this.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId);
.orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
@@ -127,12 +126,13 @@ export class SpaceRepo {
); );
} }
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
return result;
} }
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) { withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
@@ -10,7 +10,7 @@ import {
User, User,
} from '@docmost/db/types/entity.types'; } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options'; import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
@@ -145,8 +145,7 @@ export class UserRepo {
.selectFrom('users') .selectFrom('users')
.select(this.baseFields) .select(this.baseFields)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null);
.orderBy('createdAt', 'asc');
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
@@ -162,12 +161,13 @@ export class UserRepo {
); );
} }
const result = executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
return result;
} }
async updatePreference( async updatePreference(
@@ -27,7 +27,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { FileTaskIdDto } from './dto/file-task-dto'; import { FileTaskIdDto } from './dto/file-task-dto';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Controller('file-tasks') @Controller('file-tasks')
export class FileTaskController { export class FileTaskController {
@@ -56,12 +56,14 @@ export class FileTaskController {
const query = this.db const query = this.db
.selectFrom('fileTasks') .selectFrom('fileTasks')
.selectAll() .selectAll()
.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id)) .where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(user.id));
.orderBy('createdAt', 'desc');
return executeWithPagination(query, { return executeWithCursorPagination(query, {
page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
}); });
} }
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers'; import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage'; import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver { export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client; private readonly s3Client: S3Client;
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
await upload.done(); await upload.done();
} catch (err) { } catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} }
} }
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done(); await upload.done();
} catch (err) { } catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`); throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally { } finally {
if (shouldDestroyClient && clientToUse) { if (shouldDestroyClient && clientToUse) {
+10 -3
View File
@@ -24,7 +24,11 @@ async function bootstrap() {
}), }),
{ {
rawBody: true, rawBody: true,
bufferLogs: true, // disable Nest logger so pino handles all logs
// bufferLogs must be false else pino will fail
// to log OnApplicationBootstrap logs
logger: false,
bufferLogs: false,
}, },
); );
@@ -100,8 +104,11 @@ async function bootstrap() {
}); });
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => { const host = process.env.HOST || '0.0.0.0';
logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`); await app.listen(port, host, () => {
logger.log(
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
}); });
} }
@@ -23,6 +23,9 @@ export class WsRedisIoAdapter extends IoAdapter {
const pubClient = new Redis(process.env.REDIS_URL, options); const pubClient = new Redis(process.env.REDIS_URL, options);
const subClient = new Redis(process.env.REDIS_URL, options); const subClient = new Redis(process.env.REDIS_URL, options);
pubClient.on('error', (err) => () => {});
subClient.on('error', (err) => () => {});
this.adapterConstructor = createAdapter(pubClient, subClient); this.adapterConstructor = createAdapter(pubClient, subClient);
} }
+31 -28
View File
@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.24.1", "version": "0.25.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -29,37 +29,38 @@
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "3.17.1", "@tiptap/core": "3.19.0",
"@tiptap/extension-code-block": "3.17.1", "@tiptap/extension-code-block": "3.19.0",
"@tiptap/extension-collaboration": "3.17.1", "@tiptap/extension-collaboration": "3.19.0",
"@tiptap/extension-collaboration-caret": "3.17.1", "@tiptap/extension-collaboration-caret": "3.19.0",
"@tiptap/extension-color": "3.17.1", "@tiptap/extension-color": "3.19.0",
"@tiptap/extension-document": "3.17.1", "@tiptap/extension-document": "3.19.0",
"@tiptap/extension-heading": "3.17.1", "@tiptap/extension-heading": "3.19.0",
"@tiptap/extension-highlight": "3.17.1", "@tiptap/extension-highlight": "3.19.0",
"@tiptap/extension-history": "3.17.1", "@tiptap/extension-history": "3.19.0",
"@tiptap/extension-image": "3.17.1", "@tiptap/extension-image": "3.19.0",
"@tiptap/extension-link": "3.17.1", "@tiptap/extension-link": "3.19.0",
"@tiptap/extension-list": "3.17.1", "@tiptap/extension-list": "3.19.0",
"@tiptap/extension-placeholder": "3.17.1", "@tiptap/extension-placeholder": "3.19.0",
"@tiptap/extension-subscript": "3.17.1", "@tiptap/extension-subscript": "3.19.0",
"@tiptap/extension-superscript": "3.17.1", "@tiptap/extension-superscript": "3.19.0",
"@tiptap/extension-table": "3.17.1", "@tiptap/extension-table": "3.19.0",
"@tiptap/extension-text": "3.17.1", "@tiptap/extension-text": "3.19.0",
"@tiptap/extension-text-align": "3.17.1", "@tiptap/extension-text-align": "3.19.0",
"@tiptap/extension-text-style": "3.17.1", "@tiptap/extension-text-style": "3.19.0",
"@tiptap/extension-typography": "3.17.1", "@tiptap/extension-typography": "3.19.0",
"@tiptap/extension-unique-id": "^3.17.1", "@tiptap/extension-unique-id": "^3.19.0",
"@tiptap/extension-youtube": "3.17.1", "@tiptap/extension-youtube": "3.19.0",
"@tiptap/html": "3.17.1", "@tiptap/html": "3.19.0",
"@tiptap/pm": "3.17.1", "@tiptap/pm": "3.19.0",
"@tiptap/react": "3.17.1", "@tiptap/react": "3.19.0",
"@tiptap/starter-kit": "3.17.1", "@tiptap/starter-kit": "3.19.0",
"@tiptap/suggestion": "3.17.1", "@tiptap/suggestion": "3.19.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -70,6 +71,7 @@
"marked": "13.0.3", "marked": "13.0.3",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rfc6902": "5.1.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7", "y-prosemirror": "1.3.7",
@@ -98,6 +100,7 @@
"overrides": { "overrides": {
"jsdom": "25.0.1", "jsdom": "25.0.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"prosemirror-changeset": "2.3.1",
"y-prosemirror": "1.3.7" "y-prosemirror": "1.3.7"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
+2 -1
View File
@@ -8,5 +8,6 @@
}, },
"main": "dist/index.js", "main": "dist/index.js",
"module": "./src/index.ts", "module": "./src/index.ts",
"types": "dist/index.d.ts" "types": "dist/index.d.ts",
"dependencies": {}
} }
+1
View File
@@ -24,3 +24,4 @@ export * from "./lib/highlight";
export * from "./lib/heading/heading"; export * from "./lib/heading/heading";
export * from "./lib/unique-id"; export * from "./lib/unique-id";
export * from "./lib/shared-storage"; export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
@@ -0,0 +1,3 @@
export function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
@@ -0,0 +1,17 @@
import { AnyObject } from "./types";
/**
* get target value from json-pointer (e.g. /content/0/content)
* @param {AnyObject} obj object to resolve path into
* @param {string} path json-pointer
* @return {any} target value
*/
export function getFromPath(obj: AnyObject, path: string): any {
const pathParts = path.split("/");
pathParts.shift(); // remove root-entry
while (pathParts.length) {
const property = pathParts.shift();
obj = obj[property];
}
return obj;
}
@@ -0,0 +1,29 @@
import { ReplaceStep } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function getReplaceStep(fromDoc: Node, toDoc: Node) {
let start = toDoc.content.findDiffStart(fromDoc.content);
if (start === null) {
return false;
}
// @ts-ignore property access to content
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
const overlap = start - Math.min(endA, endB);
if (overlap > 0) {
// If there is an overlap, there is some freedom of choice in how to calculate the
// start/end boundary. for an inserted/removed slice. We choose the extreme with
// the lowest depth value.
if (
fromDoc.resolve(start - overlap).depth <
toDoc.resolve(endA + overlap).depth
) {
start -= overlap;
} else {
endA += overlap;
endB += overlap;
}
}
return new ReplaceStep(start, endB, toDoc.slice(start, endA));
}
@@ -0,0 +1,4 @@
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
export { recreateTransform, RecreateTransform } from "./recreateTransform";
export type { Options } from "./recreateTransform";
@@ -0,0 +1,279 @@
import { Transform } from "@tiptap/pm/transform";
import { Node, Schema } from "@tiptap/pm/model";
import { applyPatch, createPatch, Operation } from "rfc6902";
import { diffWordsWithSpace, diffChars } from "diff";
import { AnyObject } from "./types";
import { getReplaceStep } from "./getReplaceStep";
import { simplifyTransform } from "./simplifyTransform";
import { removeMarks } from "./removeMarks";
import { getFromPath } from "./getFromPath";
import { copy } from "./copy";
export interface Options {
complexSteps?: boolean;
wordDiffs?: boolean;
simplifyDiff?: boolean;
}
export class RecreateTransform {
fromDoc: Node;
toDoc: Node;
complexSteps: boolean;
wordDiffs: boolean;
simplifyDiff: boolean;
schema: Schema;
tr: Transform;
/* current working document data, may get updated while recalculating node steps */
currentJSON: AnyObject;
/* final document as json data */
finalJSON: AnyObject;
ops: Array<Operation>;
constructor(fromDoc: Node, toDoc: Node, options: Options = {}) {
const o = {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
...options,
};
this.fromDoc = fromDoc;
this.toDoc = toDoc;
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
this.simplifyDiff = o.simplifyDiff;
this.schema = fromDoc.type.schema;
this.tr = new Transform(fromDoc);
}
init() {
if (this.complexSteps) {
// For First steps: we create versions of the documents without marks as
// these will only confuse the diffing mechanism and marks won't cause
// any mapping changes anyway.
this.currentJSON = removeMarks(this.fromDoc).toJSON();
this.finalJSON = removeMarks(this.toDoc).toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
this.recreateChangeMarkSteps();
} else {
// We don't differentiate between mark changes and other changes.
this.currentJSON = this.fromDoc.toJSON();
this.finalJSON = this.toDoc.toJSON();
this.ops = createPatch(this.currentJSON, this.finalJSON);
this.recreateChangeContentSteps();
}
if (this.simplifyDiff) {
this.tr = simplifyTransform(this.tr) || this.tr;
}
return this.tr;
}
/** convert json-diff to prosemirror steps */
recreateChangeContentSteps() {
// First step: find content changing steps.
let ops = [];
while (this.ops.length) {
// get next
let op = this.ops.shift();
ops.push(op);
let toDoc;
const afterStepJSON = copy(this.currentJSON); // working document receiving patches
const pathParts = op.path.split("/");
// collect operations until we receive a valid document:
// apply ops-patches until a valid prosemirror document is retrieved,
// then try to create a transformation step or retry with next operation
while (toDoc == null) {
applyPatch(afterStepJSON, [op]);
try {
toDoc = this.schema.nodeFromJSON(afterStepJSON);
toDoc.check();
} catch (error) {
toDoc = null;
if (this.ops.length > 0) {
op = this.ops.shift();
ops.push(op);
} else {
throw new Error(`No valid diff possible applying ${op.path}`);
}
}
}
// apply operation (ignoring afterStepJSON)
if (
this.complexSteps &&
ops.length === 1 &&
(pathParts.includes("attrs") || pathParts.includes("type"))
) {
// Node markup is changing
this.addSetNodeMarkup(); // a lost update is ignored
ops = [];
// console.log("%cop", logStyle, "- update node", ops);
} else if (
ops.length === 1 &&
op.op === "replace" &&
pathParts[pathParts.length - 1] === "text"
) {
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
this.addReplaceTextSteps(op, afterStepJSON);
ops = [];
// console.log("%cop", logStyle, "- replace", ops);
} else if (this.addReplaceStep(toDoc, afterStepJSON)) {
// operations have been applied
ops = [];
// console.log("%cop", logStyle, "- other", ops);
}
}
}
/** update node with attrs and marks, may also change type */
addSetNodeMarkup() {
// first diff in document is supposed to be a node-change (in type and/or attributes)
// thus simply find the first change and apply a node change step, then recalculate the diff
// after updating the document
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
const start = toDoc.content.findDiffStart(fromDoc.content);
// @note start is the same (first) position for current and target document
const fromNode = fromDoc.nodeAt(start);
const toNode = toDoc.nodeAt(start);
if (start != null) {
// @note this completly updates all attributes in one step, by completely replacing node
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
try {
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
} catch (e) {
// if nodetypes differ, the updated node-type and contents might not be compatible
// with schema and requires a replace
if (nodeType && e.message.includes("Invalid content")) {
// @todo add test-case for this scenario
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
} else {
throw e;
}
}
this.currentJSON = removeMarks(this.tr.doc).toJSON();
// setting the node markup may have invalidated the following ops, so we calculate them again.
this.ops = createPatch(this.currentJSON, this.finalJSON);
return true;
}
return false;
}
recreateChangeMarkSteps() {
// Now the documents should be the same, except their marks, so everything should map 1:1.
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
this.toDoc.descendants((tNode, tPos) => {
if (!tNode.isInline) {
return true;
}
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
if (!fNode.isInline) {
return true;
}
const from = Math.max(tPos, fPos);
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
fNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(tNode.marks)) {
this.tr.removeMark(from, to, nodeMark);
}
});
tNode.marks.forEach((nodeMark) => {
if (!nodeMark.isInSet(fNode.marks)) {
this.tr.addMark(from, to, nodeMark);
}
});
});
});
}
/**
* retrieve and possibly apply replace-step based from doc changes
* From http://prosemirror.net/examples/footnote/
*/
addReplaceStep(toDoc: Node, afterStepJSON: AnyObject) {
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
const step = getReplaceStep(fromDoc, toDoc);
if (!step) {
return false;
} else if (!this.tr.maybeStep(step).failed) {
this.currentJSON = afterStepJSON;
return true; // @change previously null
}
throw new Error("No valid step found.");
}
/** retrieve and possibly apply text replace-steps based from doc changes */
addReplaceTextSteps(op, afterStepJSON) {
// We find the position number of the first character in the string
const op1 = { ...op, value: "xx" };
const op2 = { ...op, value: "yy" };
const afterOP1JSON = copy(this.currentJSON);
const afterOP2JSON = copy(this.currentJSON);
applyPatch(afterOP1JSON, [op1]);
applyPatch(afterOP2JSON, [op2]);
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
// get text diffs
const finalText = op.value;
const currentText = getFromPath(this.currentJSON, op.path);
const textDiffs = this.wordDiffs
? diffWordsWithSpace(currentText, finalText)
: diffChars(currentText, finalText);
let offset = op1Doc.content.findDiffStart(op2Doc.content);
const marks = op1Doc.resolve(offset + 1).marks();
while (textDiffs.length) {
const diff = textDiffs.shift();
if (diff.added) {
const textNode = this.schema
.nodeFromJSON({ type: "text", text: diff.value })
.mark(marks);
if (textDiffs.length && textDiffs[0].removed) {
const nextDiff = textDiffs.shift();
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
} else {
this.tr.insert(offset, textNode);
}
offset += diff.value.length;
} else if (diff.removed) {
if (textDiffs.length && textDiffs[0].added) {
const nextDiff = textDiffs.shift();
const textNode = this.schema
.nodeFromJSON({ type: "text", text: nextDiff.value })
.mark(marks);
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
offset += nextDiff.value.length;
} else {
this.tr.delete(offset, offset + diff.value.length);
}
} else {
offset += diff.value.length;
}
}
this.currentJSON = afterStepJSON;
}
}
export function recreateTransform(
fromDoc: Node,
toDoc: Node,
options: Options = {},
): Transform {
const recreator = new RecreateTransform(fromDoc, toDoc, options);
return recreator.init();
}
@@ -0,0 +1,8 @@
import { Transform } from "@tiptap/pm/transform";
import { Node } from "@tiptap/pm/model";
export function removeMarks(doc: Node) {
const tr = new Transform(doc);
tr.removeMark(0, doc.nodeSize - 2);
return tr.doc;
}
@@ -0,0 +1,30 @@
import { Transform, ReplaceStep, Step } from "@tiptap/pm/transform";
import { getReplaceStep } from "./getReplaceStep";
// join adjacent ReplaceSteps
export function simplifyTransform(tr: Transform) {
if (!tr.steps.length) {
return undefined;
}
const newTr = new Transform(tr.docs[0]);
const oldSteps = tr.steps.slice();
while (oldSteps.length) {
let step = oldSteps.shift();
while (oldSteps.length && step.merge(oldSteps[0])) {
const addedStep = oldSteps.shift();
if (step instanceof ReplaceStep && addedStep instanceof ReplaceStep) {
step = getReplaceStep(
newTr.doc,
addedStep.apply(step.apply(newTr.doc).doc).doc,
// @ts-ignore
) as Step<any>;
} else {
step = step.merge(addedStep);
}
}
newTr.step(step);
}
return newTr;
}
@@ -0,0 +1,3 @@
export interface AnyObject {
[p: string]: any;
}
@@ -197,11 +197,15 @@ const replace = (
}); });
const marks = Array.from(marksSet); const marks = Array.from(marksSet);
// Delete the old text and insert new text with preserved marks // Delete the old text
tr.delete(from, to); tr.delete(from, to);
tr.insert(from, state.schema.text(replaceTerm, marks));
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
if (replaceTerm) {
tr.insert(from, state.schema.text(replaceTerm, marks));
}
dispatch(tr); dispatch(tr);
} }
}; };
@@ -228,10 +232,14 @@ const replaceAll = (
}); });
const marks = Array.from(marksSet); const marks = Array.from(marksSet);
// Delete and insert with preserved marks // Delete the old text
tr.delete(from, to); tr.delete(from, to);
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
// Only insert new text if replaceTerm is not empty (allows for deletion when replaceTerm is empty)
if (replaceTerm) {
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
} }
dispatch(tr); dispatch(tr);

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