This commit is contained in:
2025-11-14 13:39:43 +08:00
commit 1b185ac23e
11 changed files with 1307 additions and 0 deletions

511
dynanote_tui.py Normal file
View File

@@ -0,0 +1,511 @@
#!/usr/bin/env python3
"""
DynaNote TUI - 使用 SM-2 算法的间隔重复闪卡系统 (Textual 版本)
"""
import os
import sys
import toml
import tempfile
import subprocess
from typing import Dict, List, Optional, Tuple
from datetime import datetime
# 导入现有模块
import hasher
import timer
import sm2
import pathlib
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Header, Footer, Button, Static, Input,
DataTable, Label, Select, TextArea,
TabbedContent, TabPane, ContentSwitcher
)
from textual.screen import Screen, ModalScreen
from textual.reactive import reactive
os.chdir(pathlib.Path(__file__).resolve().parent)
class MemoryUnit:
"""表示单个记忆单元(闪卡)"""
def __init__(self, name: str, unit_id: Optional[str] = None):
self.name = name
self.unit_id = unit_id or hasher.get_md5(name)
self.attachments = set() # 元组集合 (字符串, 创建时间戳)
self.created_time = timer.get_daystamp()
self.algodata = {
sm2.SM2Algorithm.algo_name: {
'efactor': 2.5,
'real_rept': 0,
'rept': 0,
'interval': 0,
'last_date': 0,
'next_date': 0,
'is_activated': 0,
'last_modify': timer.get_timestamp()
}
}
def to_dict(self) -> Dict:
"""将记忆单元转换为字典以便 TOML 序列化"""
return {
'name': self.name,
'attachments': list(self.attachments),
'created_time': self.created_time,
'algodata': self.algodata
}
@classmethod
def from_dict(cls, unit_id: str, data: Dict) -> 'MemoryUnit':
"""从字典创建记忆单元"""
unit = cls(data['name'], unit_id)
unit.attachments = set(tuple(att) for att in data['attachments'])
unit.created_time = data['created_time']
unit.algodata = data['algodata']
return unit
class NewUnitScreen(ModalScreen):
"""创建新记忆单元的模态屏幕"""
def compose(self) -> ComposeResult:
with Container():
yield Label("创建新记忆单元", classes="modal-title")
yield Input(placeholder="输入单元名称...", id="unit-name")
with Horizontal():
yield Button("创建", variant="primary", id="create-btn")
yield Button("取消", variant="default", id="cancel-btn")
@on(Button.Pressed, "#create-btn")
def create_unit(self) -> None:
name_input = self.query_one("#unit-name", Input)
name = name_input.value.strip()
if name:
self.dismiss(name)
@on(Button.Pressed, "#cancel-btn")
def cancel(self) -> None:
self.dismiss(None)
class MarkRatingScreen(ModalScreen):
"""评分记忆单元的模态屏幕"""
def __init__(self, unit_name: str):
super().__init__()
self.unit_name = unit_name
def compose(self) -> ComposeResult:
with Container():
yield Label(f"'{self.unit_name}' 评分", classes="modal-title")
yield Label("\nSM-2 评分标准:", classes="rating-title")
yield Label("5 - 完美回答", classes="rating-item")
yield Label("4 - 犹豫后正确回答", classes="rating-item")
yield Label("3 - 经过严重困难后回忆起正确答案", classes="rating-item")
yield Label("2 - 错误回答; 但记得正确答案", classes="rating-item")
yield Label("1 - 错误回答; 但正确答案似乎熟悉", classes="rating-item")
yield Label("0 - 完全遗忘", classes="rating-item")
with Horizontal():
for rating in [5, 4, 3, 2, 1, 0]:
yield Button(str(rating), id=f"rating-{rating}")
yield Button("取消", variant="default", id="cancel-btn")
@on(Button.Pressed)
def handle_rating(self, event: Button.Pressed) -> None:
if event.button.id and event.button.id.startswith("rating-"):
rating = int(event.button.id.split("-")[1])
self.dismiss(rating)
elif event.button.id == "cancel-btn":
self.dismiss(None)
class AttachmentScreen(ModalScreen):
"""添加附件的模态屏幕"""
def compose(self) -> ComposeResult:
with Container():
yield Label("添加附件", classes="modal-title")
yield TextArea(id="attachment-text", language="markdown")
with Horizontal():
yield Button("添加", variant="primary", id="add-btn")
yield Button("取消", variant="default", id="cancel-btn")
@on(Button.Pressed, "#add-btn")
def add_attachment(self) -> None:
text_area = self.query_one("#attachment-text", TextArea)
text = text_area.text.strip()
if text:
self.dismiss(text)
@on(Button.Pressed, "#cancel-btn")
def cancel(self) -> None:
self.dismiss(None)
class MainScreen(Screen):
"""主屏幕"""
selected_unit = reactive(None)
def __init__(self):
super().__init__()
self.data_file = './data.toml'
self.memory_units: Dict[str, MemoryUnit] = {}
self.load_data()
def load_data(self) -> None:
"""从 data.toml 文件加载记忆单元"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
data = toml.load(f)
# 处理嵌套数据
nested_data = dict()
for key, value in data.items():
if '.' in key:
parts = key.split('.')
current = nested_data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
else:
nested_data[key] = value
data = nested_data
for unit_id, unit_data in data.items():
self.memory_units[unit_id] = MemoryUnit.from_dict(unit_id, unit_data)
self.notify(f"已加载 {len(self.memory_units)} 个记忆单元", severity="information")
except Exception as e:
self.notify(f"加载数据时出错: {e}", severity="error")
else:
self.notify("未找到数据文件. 从空数据库开始.", severity="warning")
def save_data(self) -> None:
"""将记忆单元保存到 data.toml 文件"""
try:
data = {}
for unit_id, unit in self.memory_units.items():
data[unit_id] = unit.to_dict()
with open(self.data_file, 'w', encoding='utf-8') as f:
toml.dump(data, f)
self.notify("数据保存成功", severity="information")
except Exception as e:
self.notify(f"保存数据时出错: {e}", severity="error")
def compose(self) -> ComposeResult:
yield Header()
with TabbedContent():
with TabPane("记忆单元列表", id="list-tab"):
with Vertical():
yield Label("记忆单元列表", classes="section-title")
with Horizontal():
yield Button("刷新", id="refresh-btn")
yield Button("新建", id="new-btn", variant="primary")
yield Button("删除", id="delete-btn", variant="error")
yield DataTable(id="units-table")
with TabPane("单元详情", id="detail-tab"):
with Vertical():
yield Label("记忆单元详情", classes="section-title")
yield Static("未选择任何单元", id="unit-info")
with Horizontal():
yield Button("评分", id="mark-btn", variant="success")
yield Button("添加附件", id="attach-btn")
yield Button("编辑", id="edit-btn")
yield Static("", id="attachments-info")
yield Footer()
def on_mount(self) -> None:
"""挂载时初始化"""
self.setup_table()
self.update_unit_info()
def on_resume(self) -> None:
"""从模态屏幕返回时刷新数据"""
self.refresh_table()
def setup_table(self) -> None:
"""设置数据表格"""
table = self.query_one("#units-table", DataTable)
# 只在表格为空时添加列头,避免重复添加
if not table.columns:
table.add_columns(
"ID", "名称", "复习次数", "EF", "下次复习时间", "附件数"
)
# 清除现有行数据
table.clear()
today = timer.get_daystamp()
for unit in self.memory_units.values():
algodata = unit.algodata[sm2.SM2Algorithm.algo_name]
table.add_row(
unit.unit_id[:8] + "...",
unit.name,
str(algodata['real_rept']),
f"{algodata['efactor']:.2f}",
str(algodata['next_date']),
str(len(unit.attachments))
)
def update_unit_info(self) -> None:
"""更新单元详情显示"""
info_widget = self.query_one("#unit-info", Static)
attachments_widget = self.query_one("#attachments-info", Static)
if self.selected_unit:
unit = self.selected_unit
algodata = unit.algodata[sm2.SM2Algorithm.algo_name]
info_text = f"""
记忆单元: {unit.name}
ID: {unit.unit_id}
创建时间: {unit.created_time}
SM-2 算法数据:
EFactor: {algodata['efactor']}
实际复习次数: {algodata['real_rept']}
当前重复次数: {algodata['rept']}
间隔: {algodata['interval']}
上次复习: {algodata['last_date']}
下次复习: {algodata['next_date']}
是否激活: {algodata['is_activated']}
"""
info_widget.update(info_text)
if unit.attachments:
attachments_text = f"\n附件 ({len(unit.attachments)}):\n"
sorted_attachments = sorted(unit.attachments, key=lambda x: float(x[1]))
for i, (text, timestamp) in enumerate(sorted_attachments, 1):
attachments_text += f" {i}. {text[:80]}{'...' if len(text) > 80 else ''} (于 {timestamp})\n"
attachments_widget.update(attachments_text)
else:
attachments_widget.update("\n无附件")
else:
info_widget.update("未选择任何单元")
attachments_widget.update("")
@on(DataTable.RowSelected, "#units-table")
def on_row_selected(self, event: DataTable.RowSelected) -> None:
"""处理表格行选择"""
if event.row_key:
unit_id_prefix = event.row_key.value
for unit_id, unit in self.memory_units.items():
if unit_id.startswith(unit_id_prefix):
self.selected_unit = unit
self.update_unit_info()
self.notify(f"已选择单元: {unit.name}", severity="information")
break
@on(Button.Pressed, "#refresh-btn")
def refresh_table(self) -> None:
"""刷新表格"""
self.load_data() # 重新加载数据
self.setup_table()
self.update_unit_info()
self.notify("表格已刷新", severity="information")
@on(Button.Pressed, "#new-btn")
async def new_unit(self) -> None:
"""创建新单元"""
name = await self.app.push_screen_wait(NewUnitScreen())
if name:
unit = MemoryUnit(name)
self.memory_units[unit.unit_id] = unit
self.selected_unit = unit
self.setup_table()
self.update_unit_info()
self.save_data()
self.notify(f"已创建新单元: {unit.name}", severity="success")
@on(Button.Pressed, "#delete-btn")
def delete_unit(self) -> None:
"""删除选中的单元"""
if self.selected_unit:
unit_name = self.selected_unit.name
unit_id = self.selected_unit.unit_id
del self.memory_units[unit_id]
self.selected_unit = None
self.setup_table()
self.update_unit_info()
self.save_data()
self.notify(f"已删除单元: {unit_name}", severity="warning")
else:
self.notify("请先选择一个单元", severity="error")
@on(Button.Pressed, "#mark-btn")
async def mark_unit(self) -> None:
"""为选中的单元评分"""
if not self.selected_unit:
self.notify("请先选择一个单元", severity="error")
return
rating = await self.app.push_screen_wait(
MarkRatingScreen(self.selected_unit.name)
)
if rating is not None:
algodata = self.selected_unit.algodata
if algodata[sm2.SM2Algorithm.algo_name]['is_activated'] == 0:
sm2.SM2Algorithm.revisor(algodata, rating, is_new_activation=1)
algodata[sm2.SM2Algorithm.algo_name]['is_activated'] = 1
else:
sm2.SM2Algorithm.revisor(algodata, rating)
self.update_unit_info()
self.save_data()
self.notify(f"已使用评分 {rating} 更新 {self.selected_unit.name} 的 SM-2 参数",
severity="success")
@on(Button.Pressed, "#attach-btn")
async def add_attachment(self) -> None:
"""为选中的单元添加附件"""
if not self.selected_unit:
self.notify("请先选择一个单元", severity="error")
return
text = await self.app.push_screen_wait(AttachmentScreen())
if text:
attachment = (text, str(timer.get_timestamp()))
self.selected_unit.attachments.add(attachment)
self.update_unit_info()
self.save_data()
self.notify(f"附件已添加到 {self.selected_unit.name}", severity="success")
@on(Button.Pressed, "#edit-btn")
def edit_unit(self) -> None:
"""编辑选中的单元"""
if not self.selected_unit:
self.notify("请先选择一个单元", severity="error")
return
# 创建包含单元数据的临时文件
with tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) as f:
temp_file = f.name
toml.dump({self.selected_unit.unit_id: self.selected_unit.to_dict()}, f)
try:
# 启动 nano 编辑器
subprocess.run(['nano', temp_file], check=True)
# 读取编辑后的文件
with open(temp_file, 'r', encoding='utf-8') as f:
edited_data = toml.load(f)
# 更新记忆单元
if self.selected_unit.unit_id in edited_data:
updated_data = edited_data[self.selected_unit.unit_id]
self.memory_units[self.selected_unit.unit_id] = MemoryUnit.from_dict(
self.selected_unit.unit_id, updated_data
)
self.selected_unit = self.memory_units[self.selected_unit.unit_id]
self.update_unit_info()
self.save_data()
self.notify("单元更新成功", severity="success")
else:
self.notify("错误: 在编辑后的文件中未找到单元 ID", severity="error")
except subprocess.CalledProcessError:
self.notify("编辑器已关闭但未保存", severity="warning")
except Exception as e:
self.notify(f"编辑单元时出错: {e}", severity="error")
finally:
# 清理临时文件
if os.path.exists(temp_file):
os.unlink(temp_file)
class DynaNoteTUI(App):
"""DynaNote TUI 应用"""
CSS = """
.modal-title {
text-align: center;
text-style: bold;
margin: 1;
}
.section-title {
text-align: center;
text-style: bold;
margin: 1;
}
.rating-title {
text-style: bold;
margin: 1 0;
}
.rating-item {
margin: 0 0 0 2;
}
#unit-info {
margin: 1;
border: solid $primary;
padding: 1;
}
#attachments-info {
margin: 1;
border: solid $accent;
padding: 1;
}
DataTable {
height: 1fr;
}
"""
BINDINGS = [
("q", "quit", "退出"),
("s", "save", "保存数据"),
("r", "refresh", "刷新"),
("n", "new", "新建单元"),
]
def on_mount(self) -> None:
"""挂载时切换到主屏幕"""
self.push_screen(MainScreen())
def action_quit(self) -> None:
"""退出应用"""
self.exit()
def action_save(self) -> None:
"""保存数据"""
if hasattr(self.screen, 'save_data'):
self.screen.save_data()
def action_refresh(self) -> None:
"""刷新"""
if hasattr(self.screen, 'refresh_table'):
self.screen.refresh_table()
def action_new(self) -> None:
"""新建单元"""
if hasattr(self.screen, 'new_unit'):
self.screen.new_unit()
def main():
"""主入口点"""
app = DynaNoteTUI()
app.run()
if __name__ == '__main__':
main()