Compare commits

...

40 Commits

Author SHA1 Message Date
Philipinho fe3732add9 responsive highlight button 2026-02-01 23:55:10 +00:00
Philipinho 9db9eb852c fix text shift 2026-02-01 23:43:36 +00:00
Philipinho 3007060ac4 colors 2026-02-01 23:38:45 +00:00
Philipinho ba9e58ede9 remove counter 2026-02-01 19:05:58 +00:00
Philipinho f2bc0b5049 WIP 2026-02-01 18:44:25 +00:00
Philipinho eba5ae2eb3 dry 2026-02-01 18:42:06 +00:00
Philipinho b6344f2e08 mobile scroll 2026-02-01 18:18:07 +00:00
Philipinho db52b6036e add diff to mobile 2026-02-01 18:01:24 +00:00
Philipinho fe5b236d41 WIP 2026-02-01 17:54:21 +00:00
Philipinho b3c5ca6d5f WIP 2026-02-01 17:39:55 +00:00
Philipinho 483e39db1c WIP 2026-02-01 11:16:38 +00:00
Philipinho 56f476649c scroll 2026-02-01 10:52:45 +00:00
Philipinho 1b82959859 type 2026-02-01 10:46:32 +00:00
Philipinho 4d322e9157 atom 2026-02-01 02:16:53 +00:00
Philipinho 20a7acfccc fix scroll 2026-02-01 00:39:29 +00:00
Philipinho 040ad04a27 scroll 2026-02-01 00:15:58 +00:00
Philipinho d3ca1ed72c legible 2026-02-01 00:01:17 +00:00
Philipinho 207b1b593a cleanup diff count 2026-01-31 23:59:40 +00:00
Philipinho 6c664a366f fix flicker 2026-01-31 23:50:55 +00:00
Philipinho 4873f7b9ff prefetch history 2026-01-31 23:33:25 +00:00
Philipinho 718ca2b674 lazy load history 2026-01-31 23:19:56 +00:00
Philipinho e189d01ce0 dev mode history 2026-01-31 22:46:21 +00:00
Philipinho edd3754e46 WIP 2026-01-31 22:44:52 +00:00
Philipinho b2b147f1bd node deletion 2026-01-31 22:15:16 +00:00
Philipinho 129e21f728 fix inline changes in nested nodes 2026-01-31 21:59:11 +00:00
Philipinho 70124475ab nodes 2026-01-31 21:54:22 +00:00
Philipinho 7d7decb459 highlights 2026-01-31 20:03:43 +00:00
Philipinho 1eba6e93cc WIP 3 2026-01-31 19:03:26 +00:00
Philipinho cd52acc415 WIP 2 2026-01-31 19:01:43 +00:00
Philipinho a09e35ba8f WIP 2026-01-31 18:34:25 +00:00
Philipinho 8e2cf9bb02 fix 2026-01-31 16:46:40 +00:00
Philipinho cff4ba8e50 Merge branch 'main' into feat/history-diff 2026-01-31 16:40:44 +00: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
Philipinho f44bb3f121 V2 - WIP 2026-01-21 11:45:41 +00:00
Jason Norwood-Young 669ff0435f Show actual history changes 2026-01-20 22:47:29 +00:00
89 changed files with 2400 additions and 499 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-beta.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -123,6 +123,11 @@
"page": "page", "page": "page",
"Page deleted successfully": "Page deleted successfully", "Page deleted successfully": "Page deleted successfully",
"Page history": "Page history", "Page history": "Page history",
"Version history for": "Version history for",
"document": "document",
"Select version": "Select version",
"Close": "Close",
"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",
@@ -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;
} }
@@ -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,38 @@
.diffSummary {
border: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: rem(10px);
padding: rem(12px);
background: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-7)
);
}
: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);
}
@@ -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;
}
@@ -1,36 +1,204 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect } from "react"; import "./css/history-diff.module.css";
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 docOld = Node.fromJSON(schema, previousContent);
const docNew = Node.fromJSON(schema, content);
const tr = recreateTransform(docOld, docNew, {
complexSteps: true,
wordDiffs: true,
simplifyDiff: true,
});
const changeSet = ChangeSet.create(docOld).addSteps(
tr.doc,
tr.mapping.maps,
[],
);
const changes = simplifyChanges(changeSet.changes, docNew);
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;
docNew.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;
docOld.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 = docOld.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(docNew, 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,40 +122,45 @@ 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"
onClick={() => setHistoryModalOpen(false)} onClick={() => setHistoryModalOpen(false)}
> >
{t("Cancel")} {t("Close")}
</Button>
<Button size="compact-md" onClick={confirmRestore}>
{t("Restore")}
</Button> </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("Close")}
</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>
); );
} }
@@ -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>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.24.1", "version": "0.25.0-beta.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -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,7 +24,9 @@ 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;
@@ -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> {
@@ -2,7 +2,7 @@ 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 {
@@ -15,12 +15,10 @@ export class PageHistoryService {
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';
@@ -65,15 +65,15 @@ export class PageHistoryRepo {
.selectFrom('pageHistory') .selectFrom('pageHistory')
.selectAll() .selectAll()
.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(pageId: string, trx?: KyselyTransaction) {
@@ -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 }),
}); });
} }
+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);
} }
+4 -1
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-beta.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@@ -60,6 +60,7 @@
"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,145 @@
# prosemirror-changeset
This is a helper module that can turn a sequence of document changes
into a set of insertions and deletions, for example to display them in
a change-tracking interface. Such a set can be built up incrementally,
in order to do such change tracking in a halfway performant way during
live editing.
This code is licensed under an [MIT
licence](https://github.com/ProseMirror/prosemirror-changeset/blob/master/LICENSE).
## Programming interface
Insertions and deletions are represented as spans’—ranges in the
document. The deleted spans refer to the original document, whereas
the inserted ones point into the current document.
It is possible to associate arbitrary data values with such spans, for
example to track the user that made the change, the timestamp at which
it was made, or the step data necessary to invert it again.
### class Change`<Data = any>`
A replaced range with metadata associated with it.
* **`fromA`**`: number`\
The start of the range deleted/replaced in the old document.
* **`toA`**`: number`\
The end of the range in the old document.
* **`fromB`**`: number`\
The start of the range inserted in the new document.
* **`toB`**`: number`\
The end of the range in the new document.
* **`deleted`**`: readonly Span[]`\
Data associated with the deleted content. The length of these
spans adds up to `this.toA - this.fromA`.
* **`inserted`**`: readonly Span[]`\
Data associated with the inserted content. Length adds up to
`this.toB - this.fromB`.
* `static `**`merge`**`<Data>(x: readonly Change[], y: readonly Change[], combine: fn(dataA: Data, dataB: Data) → Data) → readonly Change[]`\
This merges two changesets (the end document of x should be the
start document of y) into a single one spanning the start of x to
the end of y.
### class Span`<Data = any>`
Stores metadata for a part of a change.
* **`length`**`: number`\
The length of this span.
* **`data`**`: Data`\
The data associated with this span.
### class ChangeSet`<Data = any>`
A change set tracks the changes to a document from a given point
in the past. It condenses a number of step maps down to a flat
sequence of replacements, and simplifies replacments that
partially undo themselves by comparing their content.
* **`changes`**`: readonly Change[]`\
Replaced regions.
* **`addSteps`**`(newDoc: Node, maps: readonly StepMap[], data: Data | readonly Data[]) → ChangeSet`\
Computes a new changeset by adding the given step maps and
metadata (either as an array, per-map, or as a single value to be
associated with all maps) to the current set. Will not mutate the
old set.
Note that due to simplification that happens after each add,
incrementally adding steps might create a different final set
than adding all those changes at once, since different document
tokens might be matched during simplification depending on the
boundaries of the current changed ranges.
* **`startDoc`**`: Node`\
The starting document of the change set.
* **`map`**`(f: fn(range: Span) → Data) → ChangeSet`\
Map the span's data values in the given set through a function
and construct a new set with the resulting data.
* **`changedRange`**`(b: ChangeSet, maps?: readonly StepMap[]) → {from: number, to: number}`\
Compare two changesets and return the range in which they are
changed, if any. If the document changed between the maps, pass
the maps for the steps that changed it as second argument, and
make sure the method is called on the old set and passed the new
set. The returned positions will be in new document coordinates.
* `static `**`create`**`<Data = any>(doc: Node, combine?: fn(dataA: Data, dataB: Data) → Data = (a, b) => a === b ? a : null as any, tokenEncoder?: TokenEncoder = DefaultEncoder) → ChangeSet`\
Create a changeset with the given base object and configuration.
The `combine` function is used to compare and combine metadata—it
should return null when metadata isn't compatible, and a combined
version for a merged range when it is.
When given, a token encoder determines how document tokens are
serialized and compared when diffing the content produced by
changes. The default is to just compare nodes by name and text
by character, ignoring marks and attributes.
* **`simplifyChanges`**`(changes: readonly Change[], doc: Node) → Change[]`\
Simplifies a set of changes for presentation. This makes the
assumption that having both insertions and deletions within a word
is confusing, and, when such changes occur without a word boundary
between them, they should be expanded to cover the entire set of
words (in the new document) they touch. An exception is made for
single-character replacements.
### interface TokenEncoder`<T>`
A token encoder can be passed when creating a `ChangeSet` in order
to influence the way the library runs its diffing algorithm. The
encoder determines how document tokens (such as nodes and
characters) are encoded and compared.
Note that both the encoding and the comparison may run a lot, and
doing non-trivial work in these functions could impact
performance.
* **`encodeCharacter`**`(char: number, marks: readonly Mark[]) → T`\
Encode a given character, with the given marks applied.
* **`encodeNodeStart`**`(node: Node) → T`\
Encode the start of a node or, if this is a leaf node, the
entire node.
* **`encodeNodeEnd`**`(node: Node) → T`\
Encode the end token for the given node. It is valid to encode
every end token in the same way.
* **`compareTokens`**`(a: T, b: T) → boolean`\
Compare the given tokens. Should return true when they count as
equal.
@@ -0,0 +1,30 @@
# prosemirror-recreate-transform
> reduced and modified fork of https://gitlab.com/mpapp-public/prosemirror-recreate-steps
This is a non-core module of [ProseMirror](http://prosemirror.net).
ProseMirror is a well-behaved rich semantic content editor based on
contentEditable, with support for collaborative editing and custom
document schemas.
Every change to the document is recorded by ProseMirror as a step.
This module allows recreating the steps needed to go from document
A to B should these not be available otherwise. Recreating steps
can be interesting for example in order to show the changes between
two document versions without having access to the original steps.
Recreating a `Transform` works this way:
```js
import { recreateTransform } from "@technik-sde/prosemirror-recreate-transform";
let tr = recreateTransform(
startDoc,
endDoc,
{
complexSteps: true, // Whether step types other than ReplaceStep are allowed.
wordDiffs: false, // Whether diffs in text nodes should cover entire words.
simplifyDiffs: true // Whether steps should be merged, where possible
}
);
```
@@ -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
// https://github.com/sueddeutsche/prosemirror-recreate-transform
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);
+18
View File
@@ -7,6 +7,7 @@ settings:
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
patchedDependencies: patchedDependencies:
@@ -141,6 +142,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
diff:
specifier: 8.0.3
version: 8.0.3
dompurify: dompurify:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.6 version: 3.2.6
@@ -171,6 +175,9 @@ importers:
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
rfc6902:
specifier: 5.1.2
version: 5.1.2
uuid: uuid:
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0 version: 11.1.0
@@ -6110,6 +6117,10 @@ packages:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
diff@8.0.3:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3: dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@@ -9056,6 +9067,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfc6902@5.1.2:
resolution: {integrity: sha512-zxcb+PWlE8PwX0tiKE6zP97THQ8/lHmeiwucRrJ3YFupWEmp25RmFSlB1dNTqjkovwqG4iq+u1gzJMBS3um8mA==}
rfdc@1.3.1: rfdc@1.3.1:
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==} resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
@@ -16747,6 +16761,8 @@ snapshots:
diff@5.2.0: {} diff@5.2.0: {}
diff@8.0.3: {}
dijkstrajs@1.0.3: {} dijkstrajs@1.0.3: {}
dingbat-to-unicode@1.0.1: {} dingbat-to-unicode@1.0.1: {}
@@ -20318,6 +20334,8 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rfc6902@5.1.2: {}
rfdc@1.3.1: {} rfdc@1.3.1: {}
rimraf@3.0.2: rimraf@3.0.2: