diff --git a/README-zh.md b/README-zh.md index 2981589..9773896 100644 --- a/README-zh.md +++ b/README-zh.md @@ -25,9 +25,15 @@ QueryDash的目标是开发一款类似Notion Database的Obsidian插件,但不 ## bases - +## Table视图 ![DashTableView.png](docs/DashTableView.png) +### 记忆卡片视图 +仿照anki记忆卡片, 兼容space repetition的属性, 方便复习 + +![DashMemoryCardView.png](docs/DashMemoryCardView.png) + + ## dataview ~~~markdown diff --git a/README.md b/README.md index 69de49a..18395d7 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,15 @@ more efficiently. ## bases - +## Table View ![DashTableView.png](docs/DashTableView.png) +### Memory Card View +Modeled after Anki-style flashcards; compatible with spaced repetition properties (sr-due, sr-interval, sr-ease, etc.) to make reviewing notes efficient. + + +![DashMemoryCardView.png](docs/DashMemoryCardView.png) + ## dataview **table** diff --git a/docs/DashMemoryCardView.png b/docs/DashMemoryCardView.png new file mode 100644 index 0000000..35ca6c9 Binary files /dev/null and b/docs/DashMemoryCardView.png differ diff --git a/manifest.json b/manifest.json index d23b104..b6b3396 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "querydash", "name": "QueryDash", - "version": "2.0.0", + "version": "2.0.1", "minAppVersion": "1.8.0", "description": "The new view for bases refers to an updated or additional interface that allows users to interact with base data in a different way, such as through enhanced search, sorting, and pagination features, similar to Notion. This improves usability and data management within your application. It was originally a new view for dataview, but now its functionality has been extended to bases.", "author": "lwx", diff --git a/src/main.tsx b/src/main.tsx index 270d827..0090690 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import {Plugin} from 'obsidian'; import ReactComponent from "./pages/ReactComponent"; import {Root} from 'react-dom/client'; import {DashTableView, TableViewType} from "./pages/bases/DashTableView"; +import {DashMemoryCardView, MemoryCardViewType} from "./pages/bases/DashMemoryCardView"; export default class QueryDashPlugin extends Plugin { @@ -23,6 +24,13 @@ export default class QueryDashPlugin extends Plugin { new DashTableView(controller, containerEl) }); + this.registerBasesView(MemoryCardViewType, { + name: 'DashMemoryCardView', + icon: 'lucide-graduation-cap', + factory: (controller, containerEl) => + new DashMemoryCardView(controller, containerEl) + }); + } onunload() { diff --git a/src/pages/QueryDashView.tsx b/src/pages/QueryDashView.tsx index 0457c34..5914edf 100644 --- a/src/pages/QueryDashView.tsx +++ b/src/pages/QueryDashView.tsx @@ -14,7 +14,7 @@ interface QueryDashViewDashs { const QueryDashView: React.FC = ({app, source}) => { const [sourceType, setSourceType] = useState("table"); - const [isDarkTheme, setIsDarkTheme] = useState( + const [isDarkTheme, setIsDarkTheme] = useState( document.body.classList.contains("theme-dark") ); @@ -50,9 +50,10 @@ const QueryDashView: React.FC = ({app, source}) => { return ; } else if (sourceType === "timeline") { return ; - } else if (sourceType === "task") { - return ; } + /*else if (sourceType === "task") { + return ; + }*/ }; return ( diff --git a/src/pages/bases/DashMemoryCardView.tsx b/src/pages/bases/DashMemoryCardView.tsx new file mode 100644 index 0000000..eac145d --- /dev/null +++ b/src/pages/bases/DashMemoryCardView.tsx @@ -0,0 +1,277 @@ +import {BasesView, MarkdownRenderer, QueryController, TFile,Component} from 'obsidian'; +import React, {useEffect, useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Button, Space, Card, Modal, notification} from 'antd'; +import dayjs from 'dayjs'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +dayjs.extend(isSameOrBefore); + +export const MemoryCardViewType = 'dash-memory-card-view'; + +export class DashMemoryCardView extends BasesView { + readonly type = MemoryCardViewType; + private containerEl: HTMLElement; + private sortedCards: any[] = []; + private currentIndex: number = 0; + + constructor(controller: QueryController, parentEl: HTMLElement) { + super(controller); + this.containerEl = parentEl.createDiv('memory-card-view-container'); + } + + public async onDataUpdated(): Promise { + this.containerEl.empty(); + const allCards = this.data.data; + // 按 sr-due 升序排序 + this.sortedCards = [...allCards] + .map(card => ({card, due: card.getValue('properties.sr-due')})) + .sort((a, b) => { + if (!a.due && !b.due) return 0; + if (!a.due) return 1; + if (!b.due) return -1; + return dayjs(a.due).valueOf() - dayjs(b.due).valueOf(); + }) + .map(item => item.card); + // 找到 sr-due 最近的卡片 + let idx = 0; + const now = dayjs(); + for (let i = 0; i < this.sortedCards.length; i++) { + const due = this.sortedCards[i].getValue('properties.sr-due'); + if (due && dayjs(due).isSameOrBefore(now, 'day')) { + idx = i; + break; + } + } + this.currentIndex = idx; + this.renderMemoryCard(); + } + + private async renderMemoryCard() { + const cardData = this.sortedCards[this.currentIndex]; + if (!cardData) return; + const filePath = cardData?.getValue('file.path'); + const filePathStr = typeof filePath === 'string' ? filePath : filePath?.toString() || ''; + let markdownStr = ''; + let title = ''; + let frontmatter: Record | undefined = undefined; + if (filePathStr) { + const file = this.app.vault.getAbstractFileByPath(filePathStr); + if (file instanceof TFile) { + markdownStr = await this.app.vault.read(file); + title = file.name.replace(/\.md$/, ''); + const cache = this.app.metadataCache.getFileCache(file); + if (cache?.frontmatter) { + frontmatter = cache.frontmatter; + } + } + } + const container = this.containerEl.createDiv(); + const root = createRoot(container); + root.render( + + ); + } + + private handlePrev = () => { + if (this.currentIndex > 0) { + this.currentIndex--; + this.containerEl.empty(); + this.renderMemoryCard(); + } + }; + + private handleNext = () => { + if (this.currentIndex < this.sortedCards.length - 1) { + this.currentIndex++; + this.containerEl.empty(); + this.renderMemoryCard(); + } + }; +} + +interface MemoryCardProps { + markdown: string; + app: any; + filePath: string; + title: string; + frontmatter?: Record; + onPrev: () => void; + onNext: () => void; + isPrevDisabled: boolean; + isNextDisabled: boolean; +} + +const updateSRFrontmatter = async ( + app: any, + filePath: string, + frontmatter: Record | undefined, + rating: 1 | 3 | 5, + refresh: () => void +) => { + if (!filePath) return false; + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return false; + let repetitions = Number(frontmatter?.['sr-repetitions']) || 0; + let interval = Number(frontmatter?.['sr-interval']) || 1; + let ease = Number(frontmatter?.['sr-ease']) || 2.5; + let lapses = Number(frontmatter?.['sr-lapses']) || 0; + + if (rating < 3) { + repetitions = 0; + lapses += 1; + interval = 1; + ease = Math.max(1.3, Number((ease - 0.2).toFixed(2))); + } else { + repetitions += 1; + if (repetitions === 1) { + interval = 1; + } else if (repetitions === 2) { + interval = 6; + } else { + interval = Math.round(interval * ease); + } + ease = ease + 0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02); + ease = Math.max(1.3, Number(ease.toFixed(2))); + } + const due = dayjs().add(interval, 'day'); + const now = dayjs(); + if (due.diff(now, 'day') >= 365) { + return true; + } + await app.fileManager.processFrontMatter(file, (fm: Record) => { + fm['sr-repetitions'] = repetitions; + fm['sr-interval'] = interval; + fm['sr-ease'] = ease; + fm['sr-due'] = due.format('YYYY-MM-DD'); + fm['sr-lapses'] = lapses; + }); + refresh(); + return false; +}; + +const MemoryCard: React.FC = ({markdown, app, filePath, title, frontmatter, onPrev, onNext, isPrevDisabled, isNextDisabled}) => { + const mdRef = useRef(null); + const [rendered, setRendered] = useState(false); + const [fm, setFM] = useState(frontmatter); + const [md, setMD] = useState(markdown); + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + setFM(frontmatter); + setMD(markdown); + }, [frontmatter, markdown]); + + useEffect(() => { + if (mdRef.current && !rendered) { + // 修正:传递一个 Obsidian Component 实例,避免内存泄漏 + MarkdownRenderer.render( + app, + md, + mdRef.current, + filePath, + null + ); + setRendered(true); + } + }, [md, app, filePath, rendered]); + + const refresh = async () => { + const file = app.vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + const newMD = await app.vault.read(file); + setMD(newMD); + const cache = app.metadataCache.getFileCache(file); + setFM(cache?.frontmatter); + setRendered(false); + } + }; + + const handleDifficulty = async (action: 'hard' | 'medium' | 'easy') => { + let rating: 1 | 3 | 5 = 3; + if (action === 'hard') rating = 1; + else if (action === 'medium') rating = 3; + else if (action === 'easy') rating = 5; + const isDueOverYear = await updateSRFrontmatter(app, filePath, fm, rating, refresh); + if (isDueOverYear) setShowModal(true); + }; + + return ( + <> + +
+
+
+
+ + + + + +
+ setShowModal(false)} + footer={null} + centered + > +
+ Congratulations! You have memorized this note. +
+
+ + ); +}; diff --git a/src/pages/kanbanview/KanbanView.tsx b/src/pages/kanbanview/KanbanView.tsx index 7538846..5deb824 100644 --- a/src/pages/kanbanview/KanbanView.tsx +++ b/src/pages/kanbanview/KanbanView.tsx @@ -26,8 +26,6 @@ const KanbanView: React.FC = ({app, source}) => { const executeTaskQuery = (dvApi: any, source: any, key: any) => { dvApi.query(source).then((result: any) => { - console.log("queryResult", result); - console.log("key", key); if (result.successful) { const data = parseTaskData(result.value); let filteredDataList = [...data] @@ -120,34 +118,34 @@ const KanbanView: React.FC = ({app, source}) => { useEffect(() => { - const onModify = (file: TFile) => { - setTimeout(() => { - setRefresh((prev) => !prev); // 触发刷新 - }, 300); // 延迟 500 毫秒,可根据实际情况调整 - console.log("文件已修改:", file.path); - }; + // const onModify = (file: TFile) => { + // setTimeout(() => { + // setRefresh((prev) => !prev); // 触发刷新 + // }, 300); // 延迟 500 毫秒,可根据实际情况调整 + // console.log("文件已修改:", file.path); + // }; + // + // const onCreate = (file: TFile) => { + // console.log("文件已创建:", file.path); + // setRefresh(!refresh); // 触发刷新 + // }; + // + // const onDelete = (file: TFile) => { + // console.log("文件已删除:", file.path); + // setRefresh(!refresh); // 触发刷新 + // }; - const onCreate = (file: TFile) => { - console.log("文件已创建:", file.path); - setRefresh(!refresh); // 触发刷新 - }; - - const onDelete = (file: TFile) => { - console.log("文件已删除:", file.path); - setRefresh(!refresh); // 触发刷新 - }; - - // 监听文件事件 - app.vault.on("modify", onModify); - app.vault.on("create", onCreate); - app.vault.on("delete", onDelete); - - // 清理事件监听器 - return () => { - app.vault.off("modify", onModify); - app.vault.off("create", onCreate); - app.vault.off("delete", onDelete); - }; + // // 监听文件事件 + // app.vault.on("modify", onModify); + // app.vault.on("create", onCreate); + // app.vault.on("delete", onDelete); + // + // // 清理事件监听器 + // return () => { + // app.vault.off("modify", onModify); + // app.vault.off("create", onCreate); + // app.vault.off("delete", onDelete); + // }; }, [app]); const cardDetails = (dataList: any) => {