mirror of
https://github.com/liufree/obsidian-querydash.git
synced 2026-07-22 05:41:49 +00:00
Merge pull request #33 from liufree/dev
feat: bases support mermory card view
This commit is contained in:
commit
7efd1cf2b3
8 changed files with 331 additions and 35 deletions
|
|
@ -25,9 +25,15 @@ QueryDash的目标是开发一款类似Notion Database的Obsidian插件,但不
|
|||
|
||||
## bases
|
||||
|
||||
|
||||
## Table视图
|
||||

|
||||
|
||||
### 记忆卡片视图
|
||||
仿照anki记忆卡片, 兼容space repetition的属性, 方便复习
|
||||
|
||||

|
||||
|
||||
|
||||
## dataview
|
||||
|
||||
~~~markdown
|
||||
|
|
|
|||
|
|
@ -33,9 +33,15 @@ more efficiently.
|
|||
|
||||
## bases
|
||||
|
||||
|
||||
## Table View
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
|
||||

|
||||
|
||||
## dataview
|
||||
|
||||
**table**
|
||||
|
|
|
|||
BIN
docs/DashMemoryCardView.png
Normal file
BIN
docs/DashMemoryCardView.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
277
src/pages/bases/DashMemoryCardView.tsx
Normal file
277
src/pages/bases/DashMemoryCardView.tsx
Normal 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}}>←</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}}>→</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue