hoàn thành tích hợp FSRS
This commit is contained in:
@@ -60,3 +60,7 @@ Special thanks to;
|
|||||||
|
|
||||||
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
||||||
|
|
||||||
|
|
||||||
|
cách up project lên ghcr.io
|
||||||
|
docker build -t ghcr.io/victorphan03/docmost:latest .
|
||||||
|
docker push ghcr.io/victorphan03/docmost:latest
|
||||||
|
|||||||
@@ -880,5 +880,20 @@
|
|||||||
"Try a different search term.": "Try a different search term.",
|
"Try a different search term.": "Try a different search term.",
|
||||||
"Try again": "Try again",
|
"Try again": "Try again",
|
||||||
"Untitled chat": "Untitled chat",
|
"Untitled chat": "Untitled chat",
|
||||||
"What can I help you with?": "What can I help you with?"
|
"What can I help you with?": "What can I help you with?",
|
||||||
|
"Need to Review": "Need to Review",
|
||||||
|
"How well do you know this page?": "How well do you know this page?",
|
||||||
|
"Due": "Due",
|
||||||
|
"All caught up!": "All caught up!",
|
||||||
|
"No pages need review today. Good job!": "No pages need review today. Good job!",
|
||||||
|
"Review updated": "Review updated",
|
||||||
|
"Failed to update review": "Failed to update review",
|
||||||
|
"Hard": "Hard",
|
||||||
|
"Good": "Good",
|
||||||
|
"Easy": "Easy",
|
||||||
|
"Again": "Again",
|
||||||
|
"Plan": "Plan",
|
||||||
|
"Next review": "Next review",
|
||||||
|
"No learning plan yet": "No learning plan yet",
|
||||||
|
"Try adding some cloze deletions to your notes to start learning.": "Try adding some cloze deletions to your notes to start learning."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
IconEyeOff,
|
||||||
|
IconCards,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
import classes from "./bubble-menu.module.css";
|
||||||
@@ -78,6 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isStrike: ctx.editor.isActive("strike"),
|
isStrike: ctx.editor.isActive("strike"),
|
||||||
isCode: ctx.editor.isActive("code"),
|
isCode: ctx.editor.isActive("code"),
|
||||||
isComment: ctx.editor.isActive("comment"),
|
isComment: ctx.editor.isActive("comment"),
|
||||||
|
isCloze: ctx.editor.isActive("cloze"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,6 +116,51 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
command: () => props.editor.chain().focus().toggleCode().run(),
|
command: () => props.editor.chain().focus().toggleCode().run(),
|
||||||
icon: IconCode,
|
icon: IconCode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Cloze Deletion",
|
||||||
|
isActive: () => editorState?.isCloze,
|
||||||
|
command: () => props.editor.chain().focus().toggleCloze().run(),
|
||||||
|
icon: IconEyeOff,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Send to Anki",
|
||||||
|
isActive: () => false,
|
||||||
|
command: async () => {
|
||||||
|
const { from, to } = props.editor.state.selection;
|
||||||
|
const text = props.editor.state.doc.textBetween(from, to, " ");
|
||||||
|
const title = document.title || "Docmost Page";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:8765", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "addNote",
|
||||||
|
version: 6,
|
||||||
|
params: {
|
||||||
|
note: {
|
||||||
|
deckName: "Docmost",
|
||||||
|
modelName: "Basic",
|
||||||
|
fields: {
|
||||||
|
Front: title,
|
||||||
|
Back: text,
|
||||||
|
},
|
||||||
|
tags: ["docmost"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.error) {
|
||||||
|
alert(`AnkiConnect Error: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
alert("Successfully sent to Anki!");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("Failed to connect to AnkiConnect. Make sure Anki is running with AnkiConnect plugin installed.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: IconCards,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const commentItem: BubbleMenuItem = {
|
const commentItem: BubbleMenuItem = {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
|
Cloze,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -375,6 +376,7 @@ export const mainExtensions = [
|
|||||||
}).configure(),
|
}).configure(),
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
|
Cloze,
|
||||||
AutoJoiner.configure({
|
AutoJoiner.configure({
|
||||||
elementsToJoin: [],
|
elementsToJoin: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.docmost-cloze {
|
||||||
|
background-color: var(--mantine-color-black);
|
||||||
|
color: var(--mantine-color-black);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 2px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docmost-cloze:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .docmost-cloze {
|
||||||
|
background-color: var(--mantine-color-white);
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .docmost-cloze:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@
|
|||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
@import "./columns.css";
|
@import "./columns.css";
|
||||||
@import "./status.css";
|
@import "./status.css";
|
||||||
|
@import "./cloze.css";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Text, Tabs, Space } from "@mantine/core";
|
import { Text, Tabs, Space } from "@mantine/core";
|
||||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
import { IconClockHour3, IconStar, IconUser, IconSchool, IconCalendarStats } from "@tabler/icons-react";
|
||||||
import RecentChanges from "@/components/common/recent-changes";
|
import RecentChanges from "@/components/common/recent-changes";
|
||||||
import FavoritesPages from "./favorites-pages";
|
import FavoritesPages from "./favorites-pages";
|
||||||
import CreatedByMe from "./created-by-me";
|
import CreatedByMe from "./created-by-me";
|
||||||
|
import NeedToReview from "./need-to-review";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
|
import { homeTabAtom } from "@/features/home/atoms/home-tab-atom";
|
||||||
@@ -35,6 +36,16 @@ export default function HomeTabs() {
|
|||||||
{t("Created by me")}
|
{t("Created by me")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="review" leftSection={<IconSchool size={18} />}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Need to Review")}
|
||||||
|
</Text>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="plan" leftSection={<IconCalendarStats size={18} />}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Plan")}
|
||||||
|
</Text>
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Space my="md" />
|
<Space my="md" />
|
||||||
@@ -48,6 +59,12 @@ export default function HomeTabs() {
|
|||||||
<Tabs.Panel value="created">
|
<Tabs.Panel value="created">
|
||||||
<CreatedByMe />
|
<CreatedByMe />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="review">
|
||||||
|
<NeedToReview />
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="plan">
|
||||||
|
<NeedToReview onlyDue={false} />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
UnstyledButton,
|
||||||
|
Badge,
|
||||||
|
Table,
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PageListSkeleton from "@/components/ui/page-list-skeleton";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils";
|
||||||
|
import { formattedDate } from "@/lib/time";
|
||||||
|
import { useReviewListQuery } from "@/features/page/queries/page-query";
|
||||||
|
import { IconFileDescription, IconSchool } from "@tabler/icons-react";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitialsColor } from "@/lib/get-initials-color";
|
||||||
|
|
||||||
|
export default function NeedToReview({ spaceId, onlyDue = true }: { spaceId?: string; onlyDue?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useReviewListQuery(spaceId, onlyDue);
|
||||||
|
|
||||||
|
const pages = data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageListSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <Text>{t("Failed to fetch pages")}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Table.ScrollContainer minWidth={500}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Tbody>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<Table.Tr key={page.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<UnstyledButton
|
||||||
|
component={Link}
|
||||||
|
to={buildPageUrl(
|
||||||
|
page?.space?.slug,
|
||||||
|
page.slugId,
|
||||||
|
page.title,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap">
|
||||||
|
{page.icon || (
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="gray"
|
||||||
|
size={18}
|
||||||
|
>
|
||||||
|
<IconFileDescription size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
<Text fw={500} size="md" lineClamp={1}>
|
||||||
|
{page.title || t("Untitled")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Table.Td>
|
||||||
|
{!spaceId && (
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
color={getInitialsColor(page?.space?.name || "default")}
|
||||||
|
variant="light"
|
||||||
|
component={Link}
|
||||||
|
to={getSpaceUrl(page?.space?.slug || "")}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{page?.space?.name || t("Unknown")}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
|
{onlyDue ? t("Due") : t("Next review")}: {formattedDate(page.nextReviewDate)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
fullWidth
|
||||||
|
mt="sm"
|
||||||
|
mb="xl"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{t("Load more")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={IconSchool}
|
||||||
|
title={onlyDue ? t("All caught up!") : t("No learning plan yet")}
|
||||||
|
description={onlyDue ? t("No pages need review today. Good job!") : t("Try adding some cloze deletions to your notes to start learning.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button, Group, Paper, Text, Stack } from "@mantine/core";
|
||||||
|
import { useReviewMutation } from "@/features/page/queries/page-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { IconBrain } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReviewButtons({ pageId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate, isPending } = useReviewMutation();
|
||||||
|
|
||||||
|
const handleReview = (quality: number) => {
|
||||||
|
mutate({ pageId, quality });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" mb="xl" radius="md" bg="gray.0">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconBrain size={20} color="var(--mantine-color-blue-filled)" />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t("How well do you know this page?")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => handleReview(1)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("Again")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => handleReview(2)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("Hard")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => handleReview(3)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("Good")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={() => handleReview(4)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("Easy")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
getAllSidebarPages,
|
getAllSidebarPages,
|
||||||
getDeletedPages,
|
getDeletedPages,
|
||||||
restorePage,
|
restorePage,
|
||||||
|
reviewPage,
|
||||||
|
getPagesToReview,
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
@@ -607,7 +609,33 @@ export function invalidateOnDeletePage(pageId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes"],
|
queryKey: ["recent-changes"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useReviewListQuery(spaceId?: string, onlyDue?: boolean) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["review-list", spaceId, onlyDue],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
getPagesToReview({ spaceId, onlyDue, cursor: pageParam, limit: 15 }),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||||
|
refetchOnMount: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviewMutation() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation<IPage, Error, { pageId: string; quality: number }>({
|
||||||
|
mutationFn: ({ pageId, quality }) => reviewPage(pageId, quality),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: t("Review updated") });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["review-list"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.show({ message: t("Failed to update review"), color: "red" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,3 +194,13 @@ export async function uploadFile(
|
|||||||
|
|
||||||
return req as unknown as IAttachment;
|
return req as unknown as IAttachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reviewPage(pageId: string, quality: number): Promise<IPage> {
|
||||||
|
const req = await api.post<IPage>("/pages/review", { pageId, quality });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPagesToReview(params?: QueryParams & { spaceId?: string; onlyDue?: boolean }): Promise<IPagination<IPage>> {
|
||||||
|
const req = await api.post("/pages/review-list", params);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export interface IPage {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
hasRestriction: boolean;
|
hasRestriction: boolean;
|
||||||
};
|
};
|
||||||
|
nextReviewDate?: Date;
|
||||||
|
easeFactor?: number;
|
||||||
|
repetitionCount?: number;
|
||||||
|
reviewInterval?: number;
|
||||||
|
stability?: number;
|
||||||
|
difficulty?: number;
|
||||||
|
lapses?: number;
|
||||||
|
state?: number;
|
||||||
|
lastReview?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IContributor {
|
export interface IContributor {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Text, Tabs, Space } from "@mantine/core";
|
import { Text, Tabs, Space } from "@mantine/core";
|
||||||
import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react";
|
import { IconClockHour3, IconSchool, IconStar, IconUser, IconCalendarStats } from "@tabler/icons-react";
|
||||||
import RecentChanges from "@/components/common/recent-changes";
|
import RecentChanges from "@/components/common/recent-changes";
|
||||||
import FavoritesPages from "@/features/home/components/favorites-pages";
|
import FavoritesPages from "@/features/home/components/favorites-pages";
|
||||||
import CreatedByMe from "@/features/home/components/created-by-me";
|
import CreatedByMe from "@/features/home/components/created-by-me";
|
||||||
|
import NeedToReview from "@/features/home/components/need-to-review";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -39,6 +40,16 @@ export default function SpaceHomeTabs() {
|
|||||||
{t("Created by me")}
|
{t("Created by me")}
|
||||||
</Text>
|
</Text>
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="review" leftSection={<IconSchool size={18} />}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Need to Review")}
|
||||||
|
</Text>
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="plan" leftSection={<IconCalendarStats size={18} />}>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("Plan")}
|
||||||
|
</Text>
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Space my="md" />
|
<Space my="md" />
|
||||||
@@ -52,6 +63,12 @@ export default function SpaceHomeTabs() {
|
|||||||
<Tabs.Panel value="created">
|
<Tabs.Panel value="created">
|
||||||
{space?.id && <CreatedByMe spaceId={space.id} />}
|
{space?.id && <CreatedByMe spaceId={space.id} />}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="review">
|
||||||
|
{space?.id && <NeedToReview spaceId={space.id} />}
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="plan">
|
||||||
|
{space?.id && <NeedToReview spaceId={space.id} onlyDue={false} />}
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button, Container } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import ReviewButtons from "@/features/page/components/review-buttons";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
const MemoizedFullEditor = React.memo(FullEditor);
|
const MemoizedFullEditor = React.memo(FullEditor);
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
const MemoizedPageHeader = React.memo(PageHeader);
|
||||||
@@ -99,6 +100,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
|
|
||||||
<MemoizedPageHeader readOnly={!canEdit} />
|
<MemoizedPageHeader readOnly={!canEdit} />
|
||||||
|
|
||||||
|
<Container size="900" px="xs" pt="50">
|
||||||
|
<ReviewButtons pageId={page.id} />
|
||||||
|
</Container>
|
||||||
|
|
||||||
<MemoizedFullEditor
|
<MemoizedFullEditor
|
||||||
key={page.id}
|
key={page.id}
|
||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsEnum, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export enum ReviewQuality {
|
||||||
|
BLACKOUT = 0,
|
||||||
|
WRONG = 1,
|
||||||
|
HARD = 2,
|
||||||
|
GOOD = 3,
|
||||||
|
EASY = 4,
|
||||||
|
PERFECT = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PageReviewDto {
|
||||||
|
@IsUUID()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsEnum(ReviewQuality)
|
||||||
|
quality: ReviewQuality;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
PageIdDto,
|
PageIdDto,
|
||||||
PageInfoDto,
|
PageInfoDto,
|
||||||
} from './dto/page.dto';
|
} from './dto/page.dto';
|
||||||
|
import { PageReviewDto } from './dto/page-review.dto';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@@ -647,4 +648,38 @@ export class PageController {
|
|||||||
|
|
||||||
return this.pageService.getPageBreadCrumbs(page.id);
|
return this.pageService.getPageBreadCrumbs(page.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('review')
|
||||||
|
async review(@Body() dto: PageReviewDto, @AuthUser() user: User) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
|
return this.pageService.review(dto.pageId, dto.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('review-list')
|
||||||
|
async reviewList(
|
||||||
|
@Body() dto: { spaceId?: string; onlyDue?: boolean },
|
||||||
|
@Body() pagination: PaginationOptions,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
if (dto.spaceId) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.pageService.getPagesToReview(
|
||||||
|
user.id,
|
||||||
|
pagination,
|
||||||
|
dto.spaceId,
|
||||||
|
dto.onlyDue ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
|||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||||
|
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 {
|
import {
|
||||||
CursorPaginationResult,
|
CursorPaginationResult,
|
||||||
@@ -71,7 +72,8 @@ export class PageService {
|
|||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private collaborationGateway: CollaborationGateway,
|
private collaborationGateway: CollaborationGateway,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
) {}
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
) { }
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -1066,4 +1068,133 @@ export class PageService {
|
|||||||
|
|
||||||
return pages.filter((p) => includedIds.has(p.id));
|
return pages.filter((p) => includedIds.has(p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async review(pageId: string, quality: number): Promise<Page> {
|
||||||
|
console.log('FSRS REVIEW:', { pageId, quality });
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FSRS v4.5 Weights
|
||||||
|
const w = [
|
||||||
|
0.4025, 1.4612, 3.3458, 15.6922, 5.8483, 1.2081, 1.2946, 0.0014, 1.5478,
|
||||||
|
0.133, 1.0124, 2.4467, 0.0641, 0.5044, 1.242, 0.2836, 2.8536,
|
||||||
|
];
|
||||||
|
|
||||||
|
let { stability: rawStability, difficulty: rawDifficulty, lapses, state, repetitionCount, lastReview } = page;
|
||||||
|
|
||||||
|
// Default values if null
|
||||||
|
const stability = Number(rawStability) || 0;
|
||||||
|
const difficulty = Number(rawDifficulty) || 0;
|
||||||
|
lapses = lapses || 0;
|
||||||
|
state = state || 0; // 0: New, 1: Learning, 2: Review, 3: Relearning
|
||||||
|
repetitionCount = repetitionCount || 0;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const elapsedDays = lastReview ? Math.max(0, Math.floor((now.getTime() - new Date(lastReview).getTime()) / (1000 * 60 * 60 * 24))) : 0;
|
||||||
|
|
||||||
|
let nextStability = 0;
|
||||||
|
let nextDifficulty = 0;
|
||||||
|
let nextState = state;
|
||||||
|
|
||||||
|
if (state === 0 || !lastReview) {
|
||||||
|
// First review
|
||||||
|
nextStability = w[quality - 1];
|
||||||
|
nextDifficulty = w[4] - w[5] * (quality - 3);
|
||||||
|
nextState = quality === 1 ? 1 : 2; // If Again, move to Learning, else Review
|
||||||
|
} else {
|
||||||
|
const retrievability = Math.pow(0.9, elapsedDays / stability);
|
||||||
|
|
||||||
|
if (quality === 1) {
|
||||||
|
// Failed
|
||||||
|
lapses++;
|
||||||
|
nextDifficulty = Math.min(Math.max(difficulty + w[6], 1), 10);
|
||||||
|
nextStability = w[10] * Math.pow(nextDifficulty, -w[11]) * (Math.pow(stability + 1, w[12]) - 1) * Math.exp(w[13] * (1 - retrievability));
|
||||||
|
nextState = 3; // Relearning
|
||||||
|
} else {
|
||||||
|
// Success
|
||||||
|
nextDifficulty = Math.min(Math.max(difficulty - w[6] * (quality - 3), 1), 10);
|
||||||
|
const hardPenalty = quality === 2 ? w[14] : 1;
|
||||||
|
const easyBonus = quality === 4 ? w[15] : 1;
|
||||||
|
|
||||||
|
nextStability = stability * (1 + Math.exp(w[7]) * (11 - nextDifficulty) * Math.pow(stability, -w[8]) * (Math.exp(w[9] * (1 - retrievability)) - 1) * hardPenalty * easyBonus);
|
||||||
|
nextState = 2; // Review
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
nextStability = Math.min(Math.max(nextStability, 0.1), 36500);
|
||||||
|
|
||||||
|
// FSRS interval formula: I = S * ln(target_recall) / ln(0.9)
|
||||||
|
// For default target_recall = 0.9, I = S
|
||||||
|
const exactReviewInterval = nextStability; // Keep as float for precision
|
||||||
|
const nextReviewDate = new Date();
|
||||||
|
// Convert days (stability) to minutes for precision
|
||||||
|
nextReviewDate.setMinutes(nextReviewDate.getMinutes() + Math.round(exactReviewInterval * 24 * 60));
|
||||||
|
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{
|
||||||
|
nextReviewDate,
|
||||||
|
lastReview: now,
|
||||||
|
stability: nextStability,
|
||||||
|
difficulty: nextDifficulty,
|
||||||
|
lapses,
|
||||||
|
state: nextState,
|
||||||
|
repetitionCount: repetitionCount + 1,
|
||||||
|
reviewInterval: Math.round(exactReviewInterval), // DB column is int4, must be integer
|
||||||
|
},
|
||||||
|
pageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.pageRepo.findById(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPagesToReview(userId: string, pagination: PaginationOptions, spaceId?: string, onlyDue = true) {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin('spaces', 'spaces.id', 'pages.spaceId')
|
||||||
|
.select([
|
||||||
|
'pages.id',
|
||||||
|
'pages.slugId',
|
||||||
|
'pages.title',
|
||||||
|
'pages.icon',
|
||||||
|
'pages.spaceId',
|
||||||
|
'pages.nextReviewDate',
|
||||||
|
'pages.easeFactor',
|
||||||
|
'pages.repetitionCount',
|
||||||
|
'pages.reviewInterval',
|
||||||
|
'pages.createdAt',
|
||||||
|
'pages.updatedAt',
|
||||||
|
])
|
||||||
|
.select(
|
||||||
|
sql<{ name: string; slug: string }>`json_build_object('name', spaces.name, 'slug', spaces.slug)`.as('space'),
|
||||||
|
)
|
||||||
|
.where('pages.nextReviewDate', 'is not', null)
|
||||||
|
.where('pages.deletedAt', 'is', null)
|
||||||
|
.orderBy('pages.nextReviewDate', 'asc');
|
||||||
|
|
||||||
|
if (onlyDue) {
|
||||||
|
query = query.where('pages.nextReviewDate', '<=', new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spaceId) {
|
||||||
|
query = query.where('pages.spaceId', '=', spaceId);
|
||||||
|
} else {
|
||||||
|
query = query.where('pages.spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeWithCursorPagination(query, {
|
||||||
|
perPage: pagination.limit,
|
||||||
|
cursor: pagination.cursor,
|
||||||
|
fields: [
|
||||||
|
{ expression: 'pages.nextReviewDate', direction: 'asc' },
|
||||||
|
{ expression: 'pages.id', direction: 'asc' },
|
||||||
|
],
|
||||||
|
parseCursor: (cursor: any) => ({
|
||||||
|
nextReviewDate: new Date(cursor.nextReviewDate),
|
||||||
|
id: cursor.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export class TrashCleanupService {
|
|||||||
|
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
const retentionDays =
|
const retentionDays =
|
||||||
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
workspace.trashRetentionDays != null
|
||||||
|
? Number(workspace.trashRetentionDays)
|
||||||
|
: DEFAULT_RETENTION_DAYS;
|
||||||
|
|
||||||
const retentionDate = new Date();
|
const retentionDate = new Date();
|
||||||
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
retentionDate.setDate(retentionDate.getDate() - retentionDays);
|
||||||
|
|||||||
@@ -366,9 +366,9 @@ export class WorkspaceService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
|
||||||
updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
|
updateWorkspaceDto.trashRetentionDays !== Number(ws.trashRetentionDays)
|
||||||
) {
|
) {
|
||||||
before.trashRetentionDays = ws.trashRetentionDays;
|
before.trashRetentionDays = ws.trashRetentionDays != null ? Number(ws.trashRetentionDays) : null;
|
||||||
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.addColumn('next_review_date', 'timestamptz')
|
||||||
|
.addColumn('ease_factor', 'float8', (col) => col.defaultTo(2.5).notNull())
|
||||||
|
.addColumn('repetition_count', 'int4', (col) => col.defaultTo(0).notNull())
|
||||||
|
.addColumn('review_interval', 'int4', (col) => col.defaultTo(0).notNull())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.dropColumn('next_review_date')
|
||||||
|
.dropColumn('ease_factor')
|
||||||
|
.dropColumn('repetition_count')
|
||||||
|
.dropColumn('review_interval')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.addColumn('stability', 'decimal', (col) => col.defaultTo(0))
|
||||||
|
.addColumn('difficulty', 'decimal', (col) => col.defaultTo(0))
|
||||||
|
.addColumn('lapses', 'integer', (col) => col.defaultTo(0))
|
||||||
|
.addColumn('state', 'integer', (col) => col.defaultTo(0))
|
||||||
|
.addColumn('last_review', 'timestamptz')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.dropColumn('stability')
|
||||||
|
.dropColumn('difficulty')
|
||||||
|
.dropColumn('lapses')
|
||||||
|
.dropColumn('state')
|
||||||
|
.dropColumn('last_review')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
@@ -42,6 +42,15 @@ export class PageRepo {
|
|||||||
'updatedAt',
|
'updatedAt',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
'contributorIds',
|
'contributorIds',
|
||||||
|
'nextReviewDate',
|
||||||
|
'easeFactor',
|
||||||
|
'repetitionCount',
|
||||||
|
'reviewInterval',
|
||||||
|
'stability',
|
||||||
|
'difficulty',
|
||||||
|
'lapses',
|
||||||
|
'state',
|
||||||
|
'lastReview',
|
||||||
];
|
];
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
|
|||||||
+170
-164
@@ -3,18 +3,13 @@
|
|||||||
* Please do not edit it manually.
|
* Please do not edit it manually.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ColumnType } from 'kysely';
|
import type { ColumnType } from "kysely";
|
||||||
|
|
||||||
export type Generated<T> =
|
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||||
T extends ColumnType<infer S, infer I, infer U>
|
|
||||||
? ColumnType<S, I | undefined, U>
|
? ColumnType<S, I | undefined, U>
|
||||||
: ColumnType<T, T | undefined, T>;
|
: ColumnType<T, T | undefined, T>;
|
||||||
|
|
||||||
export type Int8 = ColumnType<
|
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||||
string,
|
|
||||||
bigint | number | string,
|
|
||||||
bigint | number | string
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type Json = JsonValue;
|
export type Json = JsonValue;
|
||||||
|
|
||||||
@@ -28,17 +23,44 @@ export type JsonPrimitive = boolean | number | string | null;
|
|||||||
|
|
||||||
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||||
|
|
||||||
|
export type Numeric = ColumnType<string, number | string, number | string>;
|
||||||
|
|
||||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||||
|
|
||||||
|
export interface AiChatMessages {
|
||||||
|
chatId: string;
|
||||||
|
content: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
metadata: Json | null;
|
||||||
|
role: string;
|
||||||
|
toolCalls: Json | null;
|
||||||
|
tsv: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
userId: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChats {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
creatorId: string;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
title: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKeys {
|
export interface ApiKeys {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
|
creatorId: string;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
expiresAt: Timestamp | null;
|
expiresAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
lastUsedAt: Timestamp | null;
|
lastUsedAt: Timestamp | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
creatorId: string;
|
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,25 +115,25 @@ export interface AuthProviders {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
|
groupSync: Generated<boolean>;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
isEnabled: Generated<boolean>;
|
isEnabled: Generated<boolean>;
|
||||||
groupSync: Generated<boolean>;
|
|
||||||
ldapBaseDn: string | null;
|
ldapBaseDn: string | null;
|
||||||
ldapBindDn: string | null;
|
ldapBindDn: string | null;
|
||||||
ldapBindPassword: string | null;
|
ldapBindPassword: string | null;
|
||||||
|
ldapConfig: Generated<Json | null>;
|
||||||
ldapTlsCaCert: string | null;
|
ldapTlsCaCert: string | null;
|
||||||
ldapTlsEnabled: Generated<boolean | null>;
|
ldapTlsEnabled: Generated<boolean | null>;
|
||||||
ldapUrl: string | null;
|
ldapUrl: string | null;
|
||||||
ldapUserAttributes: Json | null;
|
ldapUserAttributes: Generated<Json | null>;
|
||||||
ldapUserSearchFilter: string | null;
|
ldapUserSearchFilter: string | null;
|
||||||
ldapConfig: Json | null;
|
|
||||||
settings: Json | null;
|
|
||||||
name: string;
|
name: string;
|
||||||
oidcClientId: string | null;
|
oidcClientId: string | null;
|
||||||
oidcClientSecret: string | null;
|
oidcClientSecret: string | null;
|
||||||
oidcIssuer: string | null;
|
oidcIssuer: string | null;
|
||||||
samlCertificate: string | null;
|
samlCertificate: string | null;
|
||||||
samlUrl: string | null;
|
samlUrl: string | null;
|
||||||
|
settings: Generated<Json | null>;
|
||||||
type: string;
|
type: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -176,14 +198,14 @@ export interface Comments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Favorites {
|
export interface Favorites {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
userId: string;
|
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
|
userId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileTasks {
|
export interface FileTasks {
|
||||||
@@ -226,6 +248,34 @@ export interface GroupUsers {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Notifications {
|
||||||
|
actorId: string | null;
|
||||||
|
archivedAt: Timestamp | null;
|
||||||
|
commentId: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
data: Json | null;
|
||||||
|
emailedAt: Timestamp | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
pageId: string | null;
|
||||||
|
pageVerificationId: string | null;
|
||||||
|
readAt: Timestamp | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
type: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageAccess {
|
||||||
|
accessLevel: string;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
creatorId: string | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageHistory {
|
export interface PageHistory {
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
contributorIds: Generated<string[] | null>;
|
contributorIds: Generated<string[] | null>;
|
||||||
@@ -244,6 +294,17 @@ export interface PageHistory {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PagePermissions {
|
||||||
|
addedById: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
groupId: string | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
pageAccessId: string;
|
||||||
|
role: string;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Pages {
|
export interface Pages {
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
contributorIds: Generated<string[] | null>;
|
contributorIds: Generated<string[] | null>;
|
||||||
@@ -252,14 +313,23 @@ export interface Pages {
|
|||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
deletedById: string | null;
|
deletedById: string | null;
|
||||||
|
difficulty: Generated<Numeric | null>;
|
||||||
|
easeFactor: Generated<number>;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
isLocked: Generated<boolean>;
|
isLocked: Generated<boolean>;
|
||||||
|
lapses: Generated<number | null>;
|
||||||
|
lastReview: Timestamp | null;
|
||||||
lastUpdatedById: string | null;
|
lastUpdatedById: string | null;
|
||||||
|
nextReviewDate: Timestamp | null;
|
||||||
parentPageId: string | null;
|
parentPageId: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
|
repetitionCount: Generated<number>;
|
||||||
|
reviewInterval: Generated<number>;
|
||||||
slugId: string;
|
slugId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
stability: Generated<Numeric | null>;
|
||||||
|
state: Generated<number | null>;
|
||||||
textContent: string | null;
|
textContent: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
tsv: string | null;
|
tsv: string | null;
|
||||||
@@ -268,6 +338,39 @@ export interface Pages {
|
|||||||
ydoc: Buffer | null;
|
ydoc: Buffer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageVerifications {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
creatorId: string | null;
|
||||||
|
data: Json | null;
|
||||||
|
expiresAt: Timestamp | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
mode: string | null;
|
||||||
|
pageId: string;
|
||||||
|
periodAmount: number | null;
|
||||||
|
periodUnit: string | null;
|
||||||
|
rejectedAt: Timestamp | null;
|
||||||
|
rejectedById: string | null;
|
||||||
|
rejectionComment: string | null;
|
||||||
|
requestedAt: Timestamp | null;
|
||||||
|
requestedById: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
status: string | null;
|
||||||
|
type: Generated<string>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
verifiedAt: Timestamp | null;
|
||||||
|
verifiedById: string | null;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageVerifiers {
|
||||||
|
addedById: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
isPrimary: Generated<boolean>;
|
||||||
|
pageVerificationId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Shares {
|
export interface Shares {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
@@ -310,6 +413,25 @@ export interface Spaces {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Templates {
|
||||||
|
collaboratorIds: string[] | null;
|
||||||
|
content: Json | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
creatorId: string | null;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
lastUpdatedById: string | null;
|
||||||
|
spaceId: string | null;
|
||||||
|
textContent: string | null;
|
||||||
|
title: string | null;
|
||||||
|
tsv: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
workspaceId: string;
|
||||||
|
ydoc: Buffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserMfa {
|
export interface UserMfa {
|
||||||
backupCodes: string[] | null;
|
backupCodes: string[] | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
@@ -329,12 +451,12 @@ export interface Users {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
email: string;
|
email: string;
|
||||||
emailVerifiedAt: Timestamp | null;
|
emailVerifiedAt: Timestamp | null;
|
||||||
|
hasGeneratedPassword: Generated<boolean>;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
invitedById: string | null;
|
invitedById: string | null;
|
||||||
lastActiveAt: Timestamp | null;
|
lastActiveAt: Timestamp | null;
|
||||||
lastLoginAt: Timestamp | null;
|
lastLoginAt: Timestamp | null;
|
||||||
locale: string | null;
|
locale: string | null;
|
||||||
hasGeneratedPassword: Generated<boolean | null>;
|
|
||||||
name: string | null;
|
name: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
@@ -344,6 +466,21 @@ export interface Users {
|
|||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSessions {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
deviceName: string | null;
|
||||||
|
expiresAt: Timestamp;
|
||||||
|
geoLocation: string | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
ipAddress: string | null;
|
||||||
|
lastActiveAt: Generated<Timestamp>;
|
||||||
|
metadata: Json | null;
|
||||||
|
revokedAt: Timestamp | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserTokens {
|
export interface UserTokens {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
expiresAt: Timestamp | null;
|
expiresAt: Timestamp | null;
|
||||||
@@ -355,6 +492,18 @@ export interface UserTokens {
|
|||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Watchers {
|
||||||
|
addedById: string | null;
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
mutedAt: Timestamp | null;
|
||||||
|
pageId: string | null;
|
||||||
|
spaceId: string;
|
||||||
|
type: string;
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceInvitations {
|
export interface WorkspaceInvitations {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -368,8 +517,7 @@ export interface WorkspaceInvitations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Workspaces {
|
export interface Workspaces {
|
||||||
auditRetentionDays: Generated<number>;
|
auditRetentionDays: Int8 | null;
|
||||||
trashRetentionDays: Generated<number>;
|
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
customDomain: string | null;
|
customDomain: string | null;
|
||||||
@@ -389,156 +537,14 @@ export interface Workspaces {
|
|||||||
settings: Json | null;
|
settings: Json | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
|
trashRetentionDays: Int8 | null;
|
||||||
trialEndAt: Timestamp | null;
|
trialEndAt: Timestamp | null;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Notifications {
|
|
||||||
id: Generated<string>;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
type: string;
|
|
||||||
actorId: string | null;
|
|
||||||
pageId: string | null;
|
|
||||||
spaceId: string | null;
|
|
||||||
commentId: string | null;
|
|
||||||
pageVerificationId: string | null;
|
|
||||||
data: Json | null;
|
|
||||||
readAt: Timestamp | null;
|
|
||||||
emailedAt: Timestamp | null;
|
|
||||||
archivedAt: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Watchers {
|
|
||||||
id: Generated<string>;
|
|
||||||
userId: string;
|
|
||||||
pageId: string | null;
|
|
||||||
spaceId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
type: string;
|
|
||||||
addedById: string | null;
|
|
||||||
mutedAt: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageAccess {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
spaceId: string;
|
|
||||||
accessLevel: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PagePermissions {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageAccessId: string;
|
|
||||||
userId: string | null;
|
|
||||||
groupId: string | null;
|
|
||||||
role: string;
|
|
||||||
addedById: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageVerifications {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
spaceId: string;
|
|
||||||
type: Generated<string>;
|
|
||||||
status: string | null;
|
|
||||||
mode: string | null;
|
|
||||||
periodAmount: number | null;
|
|
||||||
periodUnit: string | null;
|
|
||||||
verifiedAt: Timestamp | null;
|
|
||||||
verifiedById: string | null;
|
|
||||||
expiresAt: Timestamp | null;
|
|
||||||
requestedAt: Timestamp | null;
|
|
||||||
requestedById: string | null;
|
|
||||||
rejectedAt: Timestamp | null;
|
|
||||||
rejectedById: string | null;
|
|
||||||
rejectionComment: string | null;
|
|
||||||
data: Json | null;
|
|
||||||
creatorId: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageVerifiers {
|
|
||||||
id: Generated<string>;
|
|
||||||
pageVerificationId: string;
|
|
||||||
userId: string;
|
|
||||||
isPrimary: Generated<boolean>;
|
|
||||||
addedById: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Templates {
|
|
||||||
id: Generated<string>;
|
|
||||||
title: string | null;
|
|
||||||
description: string | null;
|
|
||||||
content: Json | null;
|
|
||||||
ydoc: Buffer | null;
|
|
||||||
icon: string | null;
|
|
||||||
spaceId: string | null;
|
|
||||||
workspaceId: string;
|
|
||||||
creatorId: string | null;
|
|
||||||
lastUpdatedById: string | null;
|
|
||||||
collaboratorIds: string[] | null;
|
|
||||||
textContent: string | null;
|
|
||||||
tsv: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiChats {
|
|
||||||
id: Generated<string>;
|
|
||||||
workspaceId: string;
|
|
||||||
creatorId: string;
|
|
||||||
title: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiChatMessages {
|
|
||||||
id: Generated<string>;
|
|
||||||
chatId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
userId: string | null;
|
|
||||||
role: string;
|
|
||||||
content: string | null;
|
|
||||||
toolCalls: Json | null;
|
|
||||||
metadata: Json | null;
|
|
||||||
tsv: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSessions {
|
|
||||||
id: Generated<string>;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
deviceName: string | null;
|
|
||||||
userAgent: string | null;
|
|
||||||
ipAddress: string | null;
|
|
||||||
geoLocation: string | null;
|
|
||||||
metadata: Json | null;
|
|
||||||
lastActiveAt: Generated<Timestamp>;
|
|
||||||
expiresAt: Timestamp;
|
|
||||||
revokedAt: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
aiChats: AiChats;
|
|
||||||
aiChatMessages: AiChatMessages;
|
aiChatMessages: AiChatMessages;
|
||||||
|
aiChats: AiChats;
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
audit: Audit;
|
audit: Audit;
|
||||||
@@ -553,11 +559,11 @@ export interface DB {
|
|||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageAccess: PageAccess;
|
pageAccess: PageAccess;
|
||||||
pagePermissions: PagePermissions;
|
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
|
pagePermissions: PagePermissions;
|
||||||
|
pages: Pages;
|
||||||
pageVerifications: PageVerifications;
|
pageVerifications: PageVerifications;
|
||||||
pageVerifiers: PageVerifiers;
|
pageVerifiers: PageVerifiers;
|
||||||
pages: Pages;
|
|
||||||
shares: Shares;
|
shares: Shares;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message":"Invalid User","error":"Bad Request","statusCode":400}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
--- apps/server/src/core/page/page.controller.ts
|
||||||
|
+++ apps/server/src/core/page/page.controller.ts
|
||||||
|
@@ -650,7 +650,8 @@
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('review')
|
||||||
|
- async review(@Body() dto: PageReviewDto, @AuthUser() user: User) {
|
||||||
|
+ async review(@Body() dto: PageReviewDto) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!page) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
-
|
||||||
|
- await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
|
return this.pageService.review(dto.pageId, dto.quality);
|
||||||
|
}
|
||||||
+12
-10
@@ -1,38 +1,40 @@
|
|||||||
services:
|
services:
|
||||||
docmost:
|
docmost:
|
||||||
image: docmost/docmost:latest
|
image: ghcr.io/victorphan03/docmost:v1.0.0
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
APP_URL: 'http://localhost:3000'
|
# THAY ĐỔI: Địa chỉ IP hoặc Domain của server mới
|
||||||
APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
|
APP_URL: 'http://your-server-ip:3000'
|
||||||
|
# THAY ĐỔI: Một chuỗi ký tự ngẫu nhiên dài để bảo mật
|
||||||
|
APP_SECRET: '32_KY_TU_NGAU_NHIEN_BAT_KY'
|
||||||
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
|
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
|
||||||
REDIS_URL: 'redis://redis:6379'
|
REDIS_URL: 'redis://redis:6379'
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- docmost:/app/data/storage
|
- docmost_data:/app/data/storage
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:18
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: docmost
|
POSTGRES_DB: docmost
|
||||||
POSTGRES_USER: docmost
|
POSTGRES_USER: docmost
|
||||||
POSTGRES_PASSWORD: STRONG_DB_PASSWORD
|
POSTGRES_PASSWORD: STRONG_DB_PASSWORD
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:8
|
image: redis:7-alpine
|
||||||
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
docmost:
|
docmost_data:
|
||||||
db_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ export * from "./lib/columns";
|
|||||||
export * from "./lib/status";
|
export * from "./lib/status";
|
||||||
export * from "./lib/pdf";
|
export * from "./lib/pdf";
|
||||||
export * from "./lib/resizable-nodeview";
|
export * from "./lib/resizable-nodeview";
|
||||||
|
export * from "./lib/cloze";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||||
|
|
||||||
|
export interface ClozeOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
cloze: {
|
||||||
|
/**
|
||||||
|
* Set a cloze mark
|
||||||
|
*/
|
||||||
|
setCloze: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* Toggle a cloze mark
|
||||||
|
*/
|
||||||
|
toggleCloze: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* Unset a cloze mark
|
||||||
|
*/
|
||||||
|
unsetCloze: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Cloze = Mark.create<ClozeOptions>({
|
||||||
|
name: "cloze",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "docmost-cloze",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "span.docmost-cloze",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"span",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setCloze:
|
||||||
|
() =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.setMark(this.name);
|
||||||
|
},
|
||||||
|
toggleCloze:
|
||||||
|
() =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.toggleMark(this.name);
|
||||||
|
},
|
||||||
|
unsetCloze:
|
||||||
|
() =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.unsetMark(this.name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
"Mod-Shift-c": () => this.editor.commands.toggleCloze(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const data = JSON.stringify({
|
||||||
|
spaceId: null,
|
||||||
|
onlyDue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3000,
|
||||||
|
path: '/api/pages/review-list',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': data.length,
|
||||||
|
// We need auth headers, maybe I can't easily test without a token
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => console.log(body));
|
||||||
|
});
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const w = [
|
||||||
|
0.4025, 1.4612, 3.3458, 15.6922, 5.8483, 1.2081, 1.2946, 0.0014, 1.5478,
|
||||||
|
0.133, 1.0124, 2.4467, 0.0641, 0.5044, 1.242, 0.2836, 2.8536,
|
||||||
|
];
|
||||||
|
const q1 = 1;
|
||||||
|
const s1 = w[q1-1];
|
||||||
|
const d1 = new Date();
|
||||||
|
d1.setMinutes(d1.getMinutes() + Math.round(s1 * 24 * 60));
|
||||||
|
|
||||||
|
const q2 = 2;
|
||||||
|
const s2 = w[q2-1];
|
||||||
|
const d2 = new Date();
|
||||||
|
d2.setMinutes(d2.getMinutes() + Math.round(s2 * 24 * 60));
|
||||||
|
|
||||||
|
console.log("Again:", s1, d1);
|
||||||
|
console.log("Hard:", s2, d2);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const w = [
|
||||||
|
0.4025, 1.4612, 3.3458, 15.6922, 5.8483, 1.2081, 1.2946, 0.0014, 1.5478,
|
||||||
|
0.133, 1.0124, 2.4467, 0.0641, 0.5044, 1.242, 0.2836, 2.8536,
|
||||||
|
];
|
||||||
|
const stability = 1.4612;
|
||||||
|
const difficulty = 7.05; // calculated for hard
|
||||||
|
const elapsedDays = 0;
|
||||||
|
const retrievability = Math.pow(0.9, elapsedDays / stability);
|
||||||
|
console.log("R:", retrievability);
|
||||||
|
|
||||||
|
let nextStability = 0;
|
||||||
|
const quality = 1;
|
||||||
|
|
||||||
|
nextDifficulty = Math.min(Math.max(difficulty + w[6], 1), 10);
|
||||||
|
console.log("D':", nextDifficulty);
|
||||||
|
|
||||||
|
nextStability = w[10] * Math.pow(nextDifficulty, -w[11]) * (Math.pow(stability + 1, w[12]) - 1) * Math.exp(w[13] * (1 - retrievability));
|
||||||
|
console.log("S':", nextStability);
|
||||||
|
|
||||||
|
const nextReviewDate = new Date();
|
||||||
|
nextReviewDate.setMinutes(nextReviewDate.getMinutes() + Math.round(nextStability * 24 * 60));
|
||||||
|
console.log("Date:", nextReviewDate);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const data = JSON.stringify({
|
||||||
|
pageId: '019dc96c-5f1b-7391-bd1e-1909fa793761',
|
||||||
|
quality: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: 3000,
|
||||||
|
path: '/api/pages/review',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': data.length
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => console.log('Status:', res.statusCode, 'Body:', body));
|
||||||
|
});
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { format, isToday, isYesterday } = require("date-fns");
|
||||||
|
function formattedDate(date) {
|
||||||
|
if (isToday(date)) {
|
||||||
|
return "Today, " + format(date, "h:mma");
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return "Yesterday, " + format(date, "h:mma");
|
||||||
|
} else {
|
||||||
|
return format(date, "MMM dd, yyyy, h:mma");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const d1 = new Date(now.getTime() + 0.4025 * 24 * 60 * 60 * 1000);
|
||||||
|
const d2 = new Date(now.getTime() + 1.4612 * 24 * 60 * 60 * 1000);
|
||||||
|
console.log("Again:", formattedDate(d1));
|
||||||
|
console.log("Hard:", formattedDate(d2));
|
||||||
Reference in New Issue
Block a user