Merge pull request #33 from liufree/dev

feat: bases support mermory card view
This commit is contained in:
liufree 2025-10-15 00:15:18 +08:00 committed by GitHub
commit 7efd1cf2b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 331 additions and 35 deletions

View file

@ -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

View file

@ -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**

BIN
docs/DashMemoryCardView.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View file

@ -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",

View file

@ -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() {

View file

@ -14,7 +14,7 @@ interface QueryDashViewDashs {
const QueryDashView: React.FC<QueryDashViewDashs> = ({app, source}) => {
const [sourceType, setSourceType] = useState<string>("table");
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(
document.body.classList.contains("theme-dark")
);
@ -50,9 +50,10 @@ const QueryDashView: React.FC<QueryDashViewDashs> = ({app, source}) => {
return <ListView app={app} source={source}/>;
} else if (sourceType === "timeline") {
return <TimeLineView app={app} source={source}/>;
} else if (sourceType === "task") {
return <KanbanView app={app} source={source}/>;
}
/*else if (sourceType === "task") {
return <KanbanView app={app} source={source}/>;
}*/
};
return (

View file

@ -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<void> {
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<string, any> | 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(
<MemoryCard
markdown={markdownStr}
app={this.app}
filePath={filePathStr}
title={title}
frontmatter={frontmatter}
onPrev={this.handlePrev}
onNext={this.handleNext}
isPrevDisabled={this.currentIndex === 0}
isNextDisabled={this.currentIndex === this.sortedCards.length - 1}
/>
);
}
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<string, any>;
onPrev: () => void;
onNext: () => void;
isPrevDisabled: boolean;
isNextDisabled: boolean;
}
const updateSRFrontmatter = async (
app: any,
filePath: string,
frontmatter: Record<string, any> | 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<string, any>) => {
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<MemoryCardProps> = ({markdown, app, filePath, title, frontmatter, onPrev, onNext, isPrevDisabled, isNextDisabled}) => {
const mdRef = useRef<HTMLDivElement>(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 (
<>
<Card style={{margin: '80px auto', padding: 24, marginBottom: 100}}>
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<Button
onClick={onPrev}
disabled={isPrevDisabled}
shape="circle"
icon={<span style={{fontSize: 20}}>&larr;</span>}
style={{marginRight: 32}}
/>
<div style={{flex: 1, maxWidth: 600}}>
<h2 style={{textAlign: 'center', marginBottom: 16}}>{title}</h2>
{frontmatter && (
<div style={{marginBottom: 16}}>
<table style={{width: '100%', fontSize: 14, background: '#fafafa', borderRadius: 4}}>
<tbody>
{Object.entries(frontmatter).map(([key, value]) => (
<tr key={key}>
<td style={{fontWeight: 'bold', padding: '2px 8px', width: 80}}>{key}</td>
<td style={{padding: '2px 8px'}}>{String(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div ref={mdRef} style={{overflowY: 'auto', marginBottom: 32}}/>
</div>
<Button
onClick={onNext}
disabled={isNextDisabled}
shape="circle"
icon={<span style={{fontSize: 20}}>&rarr;</span>}
style={{marginLeft: 32}}
/>
</div>
</Card>
<div style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
zIndex: 100,
background: 'rgba(255,255,255,0.97)',
boxShadow: '0 -2px 8px rgba(0,0,0,0.08)',
padding: '16px 0',
display: 'flex',
justifyContent: 'center',
}}>
<Space>
<Button type="primary" danger onClick={() => handleDifficulty('hard')}>hard</Button>
<Button type="default" onClick={() => handleDifficulty('medium')}>medium</Button>
<Button type="primary" onClick={() => handleDifficulty('easy')}>easy</Button>
</Space>
</div>
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
footer={null}
centered
>
<div style={{textAlign: 'center', fontSize: 18, padding: '24px 0'}}>
Congratulations! You have memorized this note.
</div>
</Modal>
</>
);
};

View file

@ -26,8 +26,6 @@ const KanbanView: React.FC<ViewProps> = ({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<ViewProps> = ({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) => {