From 8459801ca94c2226fbbd262564c978117083f582 Mon Sep 17 00:00:00 2001 From: hienp Date: Sun, 26 Apr 2026 19:31:29 +0700 Subject: [PATCH] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20t=C3=ADch=20h=E1=BB=A3?= =?UTF-8?q?p=20FSRS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + .../public/locales/en-US/translation.json | 17 +- .../components/bubble-menu/bubble-menu.tsx | 48 +++ .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/styles/cloze.css | 23 ++ .../src/features/editor/styles/index.css | 1 + .../features/home/components/home-tabs.tsx | 19 +- .../home/components/need-to-review.tsx | 122 +++++++ .../page/components/review-buttons.tsx | 68 ++++ .../src/features/page/queries/page-query.ts | 30 +- .../features/page/services/page-service.ts | 10 + .../src/features/page/types/page.types.ts | 9 + .../space/components/space-home-tabs.tsx | 19 +- apps/client/src/pages/page/page.tsx | 7 +- .../src/core/page/dto/page-review.dto.ts | 18 + apps/server/src/core/page/page.controller.ts | 35 ++ .../src/core/page/services/page.service.ts | 133 ++++++- .../page/services/trash-cleanup.service.ts | 4 +- .../workspace/services/workspace.service.ts | 4 +- ...6T150600-add-spaced-repetition-to-pages.ts | 21 ++ .../20260426T170000-add-fsrs-to-pages.ts | 23 ++ .../src/database/repos/page/page.repo.ts | 9 + apps/server/src/database/types/db.d.ts | 338 +++++++++--------- body.txt | 1 + bypass-auth.patch | 16 + docker-compose.yml | 22 +- packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/cloze.ts | 78 ++++ test-api.js | 24 ++ test-fsrs.js | 16 + test-math.js | 22 ++ test-review.js | 23 ++ test-time.js | 15 + 33 files changed, 997 insertions(+), 185 deletions(-) create mode 100644 apps/client/src/features/editor/styles/cloze.css create mode 100644 apps/client/src/features/home/components/need-to-review.tsx create mode 100644 apps/client/src/features/page/components/review-buttons.tsx create mode 100644 apps/server/src/core/page/dto/page-review.dto.ts create mode 100644 apps/server/src/database/migrations/20260426T150600-add-spaced-repetition-to-pages.ts create mode 100644 apps/server/src/database/migrations/20260426T170000-add-fsrs-to-pages.ts create mode 100644 body.txt create mode 100644 bypass-auth.patch create mode 100644 packages/editor-ext/src/lib/cloze.ts create mode 100644 test-api.js create mode 100644 test-fsrs.js create mode 100644 test-math.js create mode 100644 test-review.js create mode 100644 test-time.js diff --git a/README.md b/README.md index f09680e1..7571b2a5 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,7 @@ Special thanks to; [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 diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3bfe7c9f..63262695 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -880,5 +880,20 @@ "Try a different search term.": "Try a different search term.", "Try again": "Try again", "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." } diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index ec7e7baa..d84079e4 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -10,6 +10,8 @@ import { IconUnderline, IconMessage, IconSparkles, + IconEyeOff, + IconCards, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; @@ -78,6 +80,7 @@ export const EditorBubbleMenu: FC = (props) => { isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), isComment: ctx.editor.isActive("comment"), + isCloze: ctx.editor.isActive("cloze"), }; }, }); @@ -113,6 +116,51 @@ export const EditorBubbleMenu: FC = (props) => { command: () => props.editor.chain().focus().toggleCode().run(), 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 = { diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 1ad93308..ea89639a 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -52,6 +52,7 @@ import { Columns, Column, Status, + Cloze, } from "@docmost/editor-ext"; import { randomElement, @@ -375,6 +376,7 @@ export const mainExtensions = [ }).configure(), Columns, Column, + Cloze, AutoJoiner.configure({ elementsToJoin: [], }), diff --git a/apps/client/src/features/editor/styles/cloze.css b/apps/client/src/features/editor/styles/cloze.css new file mode 100644 index 00000000..25c8434b --- /dev/null +++ b/apps/client/src/features/editor/styles/cloze.css @@ -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; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 7ec4be9b..c1bea993 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -15,3 +15,4 @@ @import "./highlight.css"; @import "./columns.css"; @import "./status.css"; +@import "./cloze.css"; diff --git a/apps/client/src/features/home/components/home-tabs.tsx b/apps/client/src/features/home/components/home-tabs.tsx index c899a24d..f8b1702f 100644 --- a/apps/client/src/features/home/components/home-tabs.tsx +++ b/apps/client/src/features/home/components/home-tabs.tsx @@ -1,8 +1,9 @@ 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 FavoritesPages from "./favorites-pages"; import CreatedByMe from "./created-by-me"; +import NeedToReview from "./need-to-review"; import { useTranslation } from "react-i18next"; import { useAtom } from "jotai"; import { homeTabAtom } from "@/features/home/atoms/home-tab-atom"; @@ -35,6 +36,16 @@ export default function HomeTabs() { {t("Created by me")} + }> + + {t("Need to Review")} + + + }> + + {t("Plan")} + + @@ -48,6 +59,12 @@ export default function HomeTabs() { + + + + + + ); } diff --git a/apps/client/src/features/home/components/need-to-review.tsx b/apps/client/src/features/home/components/need-to-review.tsx new file mode 100644 index 00000000..2e955cf8 --- /dev/null +++ b/apps/client/src/features/home/components/need-to-review.tsx @@ -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 ; + } + + if (isError) { + return {t("Failed to fetch pages")}; + } + + return pages.length > 0 ? ( + <> + + + + {pages.map((page) => ( + + + + + {page.icon || ( + + + + )} + + {page.title || t("Untitled")} + + + + + {!spaceId && ( + + + {page?.space?.name || t("Unknown")} + + + )} + + + {onlyDue ? t("Due") : t("Next review")}: {formattedDate(page.nextReviewDate)} + + + + ))} + +
+
+ {hasNextPage && ( + + )} + + ) : ( + + ); +} diff --git a/apps/client/src/features/page/components/review-buttons.tsx b/apps/client/src/features/page/components/review-buttons.tsx new file mode 100644 index 00000000..82adb5f3 --- /dev/null +++ b/apps/client/src/features/page/components/review-buttons.tsx @@ -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 ( + + + + + + {t("How well do you know this page?")} + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 89526aa6..649eda69 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -21,6 +21,8 @@ import { getAllSidebarPages, getDeletedPages, restorePage, + reviewPage, + getPagesToReview, } from "@/features/page/services/page-service"; import { IMovePage, @@ -607,7 +609,33 @@ export function invalidateOnDeletePage(pageId: string) { }); //update recent changes - queryClient.invalidateQueries({ +queryClient.invalidateQueries({ 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({ + 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" }); + }, + }); +} diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 146da7dd..94915af2 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -194,3 +194,13 @@ export async function uploadFile( return req as unknown as IAttachment; } + +export async function reviewPage(pageId: string, quality: number): Promise { + const req = await api.post("/pages/review", { pageId, quality }); + return req.data; +} + +export async function getPagesToReview(params?: QueryParams & { spaceId?: string; onlyDue?: boolean }): Promise> { + const req = await api.post("/pages/review-list", params); + return req.data; +} diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 0bba09ff..c2ce35dc 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -28,6 +28,15 @@ export interface IPage { canEdit: boolean; hasRestriction: boolean; }; + nextReviewDate?: Date; + easeFactor?: number; + repetitionCount?: number; + reviewInterval?: number; + stability?: number; + difficulty?: number; + lapses?: number; + state?: number; + lastReview?: Date; } export interface IContributor { diff --git a/apps/client/src/features/space/components/space-home-tabs.tsx b/apps/client/src/features/space/components/space-home-tabs.tsx index b9862b5f..eccf6738 100644 --- a/apps/client/src/features/space/components/space-home-tabs.tsx +++ b/apps/client/src/features/space/components/space-home-tabs.tsx @@ -1,8 +1,9 @@ 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 FavoritesPages from "@/features/home/components/favorites-pages"; 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 { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query"; import { useTranslation } from "react-i18next"; @@ -39,6 +40,16 @@ export default function SpaceHomeTabs() { {t("Created by me")} + }> + + {t("Need to Review")} + + + }> + + {t("Plan")} + + @@ -52,6 +63,12 @@ export default function SpaceHomeTabs() { {space?.id && } + + {space?.id && } + + + {space?.id && } + ); } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index b4e3baa8..38a873ea 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -10,8 +10,9 @@ import { useTranslation } from "react-i18next"; import React from "react"; import { EmptyState } from "@/components/ui/empty-state.tsx"; 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 ReviewButtons from "@/features/page/components/review-buttons"; import { ErrorBoundary } from "react-error-boundary"; const MemoizedFullEditor = React.memo(FullEditor); const MemoizedPageHeader = React.memo(PageHeader); @@ -99,6 +100,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { + + + + includedIds.has(p.id)); } + + async review(pageId: string, quality: number): Promise { + 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, + }), + }); + } } diff --git a/apps/server/src/core/page/services/trash-cleanup.service.ts b/apps/server/src/core/page/services/trash-cleanup.service.ts index 42a4a11a..17558fc0 100644 --- a/apps/server/src/core/page/services/trash-cleanup.service.ts +++ b/apps/server/src/core/page/services/trash-cleanup.service.ts @@ -32,7 +32,9 @@ export class TrashCleanupService { for (const workspace of workspaces) { const retentionDays = - workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS; + workspace.trashRetentionDays != null + ? Number(workspace.trashRetentionDays) + : DEFAULT_RETENTION_DAYS; const retentionDate = new Date(); retentionDate.setDate(retentionDate.getDate() - retentionDays); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 72608951..8e5c5efa 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -366,9 +366,9 @@ export class WorkspaceService { if ( 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; } } diff --git a/apps/server/src/database/migrations/20260426T150600-add-spaced-repetition-to-pages.ts b/apps/server/src/database/migrations/20260426T150600-add-spaced-repetition-to-pages.ts new file mode 100644 index 00000000..42e122ca --- /dev/null +++ b/apps/server/src/database/migrations/20260426T150600-add-spaced-repetition-to-pages.ts @@ -0,0 +1,21 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema + .alterTable('pages') + .dropColumn('next_review_date') + .dropColumn('ease_factor') + .dropColumn('repetition_count') + .dropColumn('review_interval') + .execute(); +} diff --git a/apps/server/src/database/migrations/20260426T170000-add-fsrs-to-pages.ts b/apps/server/src/database/migrations/20260426T170000-add-fsrs-to-pages.ts new file mode 100644 index 00000000..65e3991d --- /dev/null +++ b/apps/server/src/database/migrations/20260426T170000-add-fsrs-to-pages.ts @@ -0,0 +1,23 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema + .alterTable('pages') + .dropColumn('stability') + .dropColumn('difficulty') + .dropColumn('lapses') + .dropColumn('state') + .dropColumn('last_review') + .execute(); +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index a35234f6..47bc6197 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -42,6 +42,15 @@ export class PageRepo { 'updatedAt', 'deletedAt', 'contributorIds', + 'nextReviewDate', + 'easeFactor', + 'repetitionCount', + 'reviewInterval', + 'stability', + 'difficulty', + 'lapses', + 'state', + 'lastReview', ]; async findById( diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 0890df93..e58132a4 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -3,18 +3,13 @@ * Please do not edit it manually. */ -import type { ColumnType } from 'kysely'; +import type { ColumnType } from "kysely"; -export type Generated = - T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; -export type Int8 = ColumnType< - string, - bigint | number | string, - bigint | number | string ->; +export type Int8 = ColumnType; export type Json = JsonValue; @@ -28,17 +23,44 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; +export type Numeric = ColumnType; + export type Timestamp = ColumnType; +export interface AiChatMessages { + chatId: string; + content: string | null; + createdAt: Generated; + deletedAt: Timestamp | null; + id: Generated; + metadata: Json | null; + role: string; + toolCalls: Json | null; + tsv: string | null; + updatedAt: Generated; + userId: string | null; + workspaceId: string; +} + +export interface AiChats { + createdAt: Generated; + creatorId: string; + deletedAt: Timestamp | null; + id: Generated; + title: string | null; + updatedAt: Generated; + workspaceId: string; +} + export interface ApiKeys { createdAt: Generated; + creatorId: string; deletedAt: Timestamp | null; expiresAt: Timestamp | null; id: Generated; lastUsedAt: Timestamp | null; name: string | null; updatedAt: Generated; - creatorId: string; workspaceId: string; } @@ -93,25 +115,25 @@ export interface AuthProviders { createdAt: Generated; creatorId: string | null; deletedAt: Timestamp | null; + groupSync: Generated; id: Generated; isEnabled: Generated; - groupSync: Generated; ldapBaseDn: string | null; ldapBindDn: string | null; ldapBindPassword: string | null; + ldapConfig: Generated; ldapTlsCaCert: string | null; ldapTlsEnabled: Generated; ldapUrl: string | null; - ldapUserAttributes: Json | null; + ldapUserAttributes: Generated; ldapUserSearchFilter: string | null; - ldapConfig: Json | null; - settings: Json | null; name: string; oidcClientId: string | null; oidcClientSecret: string | null; oidcIssuer: string | null; samlCertificate: string | null; samlUrl: string | null; + settings: Generated; type: string; updatedAt: Generated; workspaceId: string; @@ -176,14 +198,14 @@ export interface Comments { } export interface Favorites { + createdAt: Generated; id: Generated; - userId: string; pageId: string | null; spaceId: string | null; templateId: string | null; type: string; + userId: string; workspaceId: string; - createdAt: Generated; } export interface FileTasks { @@ -226,6 +248,34 @@ export interface GroupUsers { userId: string; } +export interface Notifications { + actorId: string | null; + archivedAt: Timestamp | null; + commentId: string | null; + createdAt: Generated; + data: Json | null; + emailedAt: Timestamp | null; + id: Generated; + 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; + creatorId: string | null; + id: Generated; + pageId: string; + spaceId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface PageHistory { content: Json | null; contributorIds: Generated; @@ -244,6 +294,17 @@ export interface PageHistory { workspaceId: string; } +export interface PagePermissions { + addedById: string | null; + createdAt: Generated; + groupId: string | null; + id: Generated; + pageAccessId: string; + role: string; + updatedAt: Generated; + userId: string | null; +} + export interface Pages { content: Json | null; contributorIds: Generated; @@ -252,14 +313,23 @@ export interface Pages { creatorId: string | null; deletedAt: Timestamp | null; deletedById: string | null; + difficulty: Generated; + easeFactor: Generated; icon: string | null; id: Generated; isLocked: Generated; + lapses: Generated; + lastReview: Timestamp | null; lastUpdatedById: string | null; + nextReviewDate: Timestamp | null; parentPageId: string | null; position: string | null; + repetitionCount: Generated; + reviewInterval: Generated; slugId: string; spaceId: string; + stability: Generated; + state: Generated; textContent: string | null; title: string | null; tsv: string | null; @@ -268,6 +338,39 @@ export interface Pages { ydoc: Buffer | null; } +export interface PageVerifications { + createdAt: Generated; + creatorId: string | null; + data: Json | null; + expiresAt: Timestamp | null; + id: Generated; + 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; + updatedAt: Generated; + verifiedAt: Timestamp | null; + verifiedById: string | null; + workspaceId: string; +} + +export interface PageVerifiers { + addedById: string | null; + createdAt: Generated; + id: Generated; + isPrimary: Generated; + pageVerificationId: string; + userId: string; +} + export interface Shares { createdAt: Generated; creatorId: string | null; @@ -310,6 +413,25 @@ export interface Spaces { workspaceId: string; } +export interface Templates { + collaboratorIds: string[] | null; + content: Json | null; + createdAt: Generated; + creatorId: string | null; + deletedAt: Timestamp | null; + description: string | null; + icon: string | null; + id: Generated; + lastUpdatedById: string | null; + spaceId: string | null; + textContent: string | null; + title: string | null; + tsv: string | null; + updatedAt: Generated; + workspaceId: string; + ydoc: Buffer | null; +} + export interface UserMfa { backupCodes: string[] | null; createdAt: Generated; @@ -329,12 +451,12 @@ export interface Users { deletedAt: Timestamp | null; email: string; emailVerifiedAt: Timestamp | null; + hasGeneratedPassword: Generated; id: Generated; invitedById: string | null; lastActiveAt: Timestamp | null; lastLoginAt: Timestamp | null; locale: string | null; - hasGeneratedPassword: Generated; name: string | null; password: string | null; role: string | null; @@ -344,6 +466,21 @@ export interface Users { workspaceId: string | null; } +export interface UserSessions { + createdAt: Generated; + deviceName: string | null; + expiresAt: Timestamp; + geoLocation: string | null; + id: Generated; + ipAddress: string | null; + lastActiveAt: Generated; + metadata: Json | null; + revokedAt: Timestamp | null; + userAgent: string | null; + userId: string; + workspaceId: string; +} + export interface UserTokens { createdAt: Generated; expiresAt: Timestamp | null; @@ -355,6 +492,18 @@ export interface UserTokens { workspaceId: string | null; } +export interface Watchers { + addedById: string | null; + createdAt: Generated; + id: Generated; + mutedAt: Timestamp | null; + pageId: string | null; + spaceId: string; + type: string; + userId: string; + workspaceId: string; +} + export interface WorkspaceInvitations { createdAt: Generated; email: string | null; @@ -368,8 +517,7 @@ export interface WorkspaceInvitations { } export interface Workspaces { - auditRetentionDays: Generated; - trashRetentionDays: Generated; + auditRetentionDays: Int8 | null; billingEmail: string | null; createdAt: Generated; customDomain: string | null; @@ -389,156 +537,14 @@ export interface Workspaces { settings: Json | null; status: string | null; stripeCustomerId: string | null; + trashRetentionDays: Int8 | null; trialEndAt: Timestamp | null; updatedAt: Generated; } -export interface Notifications { - id: Generated; - 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; -} - -export interface Watchers { - id: Generated; - userId: string; - pageId: string | null; - spaceId: string; - workspaceId: string; - type: string; - addedById: string | null; - mutedAt: Timestamp | null; - createdAt: Generated; -} - -export interface PageAccess { - id: Generated; - pageId: string; - workspaceId: string; - spaceId: string; - accessLevel: string; - creatorId: string | null; - createdAt: Generated; - updatedAt: Generated; -} - -export interface PagePermissions { - id: Generated; - pageAccessId: string; - userId: string | null; - groupId: string | null; - role: string; - addedById: string | null; - createdAt: Generated; - updatedAt: Generated; -} - -export interface PageVerifications { - id: Generated; - pageId: string; - workspaceId: string; - spaceId: string; - type: Generated; - 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; - updatedAt: Generated; -} - -export interface PageVerifiers { - id: Generated; - pageVerificationId: string; - userId: string; - isPrimary: Generated; - addedById: string | null; - createdAt: Generated; -} - -export interface Templates { - id: Generated; - 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; - updatedAt: Generated; - deletedAt: Timestamp | null; -} - -export interface AiChats { - id: Generated; - workspaceId: string; - creatorId: string; - title: string | null; - createdAt: Generated; - updatedAt: Generated; - deletedAt: Timestamp | null; -} - -export interface AiChatMessages { - id: Generated; - chatId: string; - workspaceId: string; - userId: string | null; - role: string; - content: string | null; - toolCalls: Json | null; - metadata: Json | null; - tsv: string | null; - createdAt: Generated; - updatedAt: Generated; - deletedAt: Timestamp | null; -} - -export interface UserSessions { - id: Generated; - userId: string; - workspaceId: string; - deviceName: string | null; - userAgent: string | null; - ipAddress: string | null; - geoLocation: string | null; - metadata: Json | null; - lastActiveAt: Generated; - expiresAt: Timestamp; - revokedAt: Timestamp | null; - createdAt: Generated; -} - export interface DB { - aiChats: AiChats; aiChatMessages: AiChatMessages; + aiChats: AiChats; apiKeys: ApiKeys; attachments: Attachments; audit: Audit; @@ -553,11 +559,11 @@ export interface DB { groupUsers: GroupUsers; notifications: Notifications; pageAccess: PageAccess; - pagePermissions: PagePermissions; pageHistory: PageHistory; + pagePermissions: PagePermissions; + pages: Pages; pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; - pages: Pages; shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; diff --git a/body.txt b/body.txt new file mode 100644 index 00000000..b07ce426 --- /dev/null +++ b/body.txt @@ -0,0 +1 @@ +{"message":"Invalid User","error":"Bad Request","statusCode":400} \ No newline at end of file diff --git a/bypass-auth.patch b/bypass-auth.patch new file mode 100644 index 00000000..5ed0acea --- /dev/null +++ b/bypass-auth.patch @@ -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); + } diff --git a/docker-compose.yml b/docker-compose.yml index 606c229b..70e653af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,38 +1,40 @@ services: docmost: - image: docmost/docmost:latest + image: ghcr.io/victorphan03/docmost:v1.0.0 depends_on: - db - redis environment: - APP_URL: 'http://localhost:3000' - APP_SECRET: 'REPLACE_WITH_LONG_SECRET' + # THAY ĐỔI: Địa chỉ IP hoặc Domain của server mới + 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' REDIS_URL: 'redis://redis:6379' ports: - "3000:3000" restart: unless-stopped volumes: - - docmost:/app/data/storage + - docmost_data:/app/data/storage db: - image: postgres:18 + image: postgres:16-alpine environment: POSTGRES_DB: docmost POSTGRES_USER: docmost POSTGRES_PASSWORD: STRONG_DB_PASSWORD restart: unless-stopped volumes: - - db_data:/var/lib/postgresql + - postgres_data:/var/lib/postgresql/data redis: - image: redis:8 - command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"] + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] restart: unless-stopped volumes: - redis_data:/data volumes: - docmost: - db_data: + docmost_data: + postgres_data: redis_data: diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index f338bcfa..79f64a3e 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -30,4 +30,5 @@ export * from "./lib/columns"; export * from "./lib/status"; export * from "./lib/pdf"; export * from "./lib/resizable-nodeview"; +export * from "./lib/cloze"; diff --git a/packages/editor-ext/src/lib/cloze.ts b/packages/editor-ext/src/lib/cloze.ts new file mode 100644 index 00000000..07c65b07 --- /dev/null +++ b/packages/editor-ext/src/lib/cloze.ts @@ -0,0 +1,78 @@ +import { Mark, mergeAttributes } from "@tiptap/core"; + +export interface ClozeOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + cloze: { + /** + * Set a cloze mark + */ + setCloze: () => ReturnType; + /** + * Toggle a cloze mark + */ + toggleCloze: () => ReturnType; + /** + * Unset a cloze mark + */ + unsetCloze: () => ReturnType; + }; + } +} + +export const Cloze = Mark.create({ + 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(), + }; + }, +}); diff --git a/test-api.js b/test-api.js new file mode 100644 index 00000000..e27c7983 --- /dev/null +++ b/test-api.js @@ -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(); diff --git a/test-fsrs.js b/test-fsrs.js new file mode 100644 index 00000000..b810f680 --- /dev/null +++ b/test-fsrs.js @@ -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); diff --git a/test-math.js b/test-math.js new file mode 100644 index 00000000..58af549d --- /dev/null +++ b/test-math.js @@ -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); diff --git a/test-review.js b/test-review.js new file mode 100644 index 00000000..dad9ca0c --- /dev/null +++ b/test-review.js @@ -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(); diff --git a/test-time.js b/test-time.js new file mode 100644 index 00000000..086bca0e --- /dev/null +++ b/test-time.js @@ -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));