Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 436f897711 | |||
| 9d6cea0453 | |||
| c9433a5e98 | |||
| 7d3c5c2ae1 | |||
| 51c76ebd24 | |||
| facf39e778 | |||
| 51cecf1f4e | |||
| 79e4eb4de0 | |||
| 70b40cb90b | |||
| 22c32a9f3d | |||
| c3791ce961 | |||
| 27e24e2a86 | |||
| 0864c99b75 | |||
| 0c6057de28 | |||
| 6f20e5978d | |||
| 56ff1e8163 | |||
| 9ec479415a | |||
| 7652718803 | |||
| d2f64f98a1 | |||
| 161b93f368 | |||
| 31f2d8b428 | |||
| 5d6dd10dfa | |||
| d0f7b07260 | |||
| 0be768ca8e | |||
| 68ecb16303 | |||
| 82d928f497 | |||
| c99eca35f0 | |||
| 47b195ed1c | |||
| 1f1528979c | |||
| bcb24e474d |
32
README.md
32
README.md
@@ -141,6 +141,38 @@ xian-favor/
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v2.4.0** (2026-04-16): 数据库备份机制
|
||||
- 自动备份:每天 04:00 执行
|
||||
- 手动备份:页面一键操作
|
||||
- 备份清理规则:保留30天 + 每月第一天永久保留
|
||||
- 手动备份最多保留10个
|
||||
- 支持恢复备份和删除备份
|
||||
- 备份管理页面入口在侧边栏
|
||||
- **v2.3.2** (2026-04-16): 搜索功能修复
|
||||
- 修复 debounce 函数定义顺序问题
|
||||
- 搜索框输入后可正常过滤列表
|
||||
- **v2.3.1** (2026-04-16): 日期放到标题后面,不增加卡片高度
|
||||
- 日期紧跟标题,同一行显示
|
||||
- 格式紧凑:`04-16 11:09→04-16 12:00`
|
||||
- **v2.3.0** (2026-04-16): 卡片显示创建和更新日期
|
||||
- 每个收藏卡片底部显示日期
|
||||
- 格式:04-16 11:09(月-日 时:分)
|
||||
- 有更新时显示:创建 → 更新
|
||||
- 字体更小更淡,不影响卡片高度
|
||||
- **v2.2.0** (2026-04-16): 快捷添加按钮,一键选择类型
|
||||
- 顶部按钮栏分离为4个快捷添加按钮(文本、链接、待办、专栏)
|
||||
- 点击直接进入对应类型的添加弹窗
|
||||
- 弹窗标题显示类型图标和名称
|
||||
- 不再需要下拉选择类型,操作更快捷
|
||||
- **v2.1.0** (2026-04-16): 待办截止时间支持日期+时间
|
||||
- 截止日期改为日期时间选择器
|
||||
- 列表显示友好格式:今天 18:30、明天 09:00 等
|
||||
- 详情页显示完整格式:2026年4月16日 18:30
|
||||
- 后端支持多种日期格式解析
|
||||
- 只有日期的待办视为当天 23:59 到期
|
||||
- **v2.0.1** (2026-04-16): 转换弹窗优化
|
||||
- 内容预览保留换行格式,提高可读性
|
||||
- 转换方式默认改为复制创建
|
||||
- **v2.0.0** (2026-04-16): 收藏转待办功能(大版本更新)
|
||||
- 新增 `/api/items/<id>/convert` API
|
||||
- **直接转换**:原收藏变为待办,数据合并
|
||||
|
||||
35
scripts/auto_backup.py
Executable file
35
scripts/auto_backup.py
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Xian Favor 自动备份脚本
|
||||
定时任务调用此脚本进行自动备份
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from xian_favor.db import db
|
||||
|
||||
def main():
|
||||
"""执行自动备份"""
|
||||
print(f"[{datetime.now().isoformat()}] 开始自动备份...")
|
||||
|
||||
try:
|
||||
backup_info = db.create_backup(manual=False)
|
||||
print(f"备份成功: {backup_info['name']}")
|
||||
print(f"大小: {backup_info['size']} bytes")
|
||||
print(f"位置: {backup_info['path']}")
|
||||
|
||||
# 清理旧备份
|
||||
db._cleanup_old_backups()
|
||||
print("旧备份清理完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f"备份失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
from datetime import datetime
|
||||
main()
|
||||
1198
xian_favor/api.py
1198
xian_favor/api.py
File diff suppressed because it is too large
Load Diff
586
xian_favor/db.py
586
xian_favor/db.py
@@ -2,12 +2,17 @@
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .config import DATABASE_URL, TODO_STATUS, PRIORITY_LEVELS
|
||||
|
||||
# 备份目录
|
||||
BACKUP_DIR = os.path.join(os.path.dirname(DATABASE_URL), 'backups')
|
||||
|
||||
|
||||
class Database:
|
||||
"""SQLite数据库管理"""
|
||||
@@ -54,6 +59,10 @@ class Database:
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date TEXT,
|
||||
note TEXT,
|
||||
is_starred INTEGER DEFAULT 0,
|
||||
views INTEGER DEFAULT 0,
|
||||
is_deleted INTEGER DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
@@ -90,6 +99,26 @@ class Database:
|
||||
)
|
||||
""")
|
||||
|
||||
# 草稿表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS drafts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
url TEXT,
|
||||
source TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date TEXT,
|
||||
note TEXT,
|
||||
tags TEXT,
|
||||
is_starred INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# 邮件发送记录表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_logs (
|
||||
@@ -109,6 +138,31 @@ class Database:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_item ON item_tags(item_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_item_tags_tag ON item_tags(tag_id)")
|
||||
|
||||
# 检查并添加 is_starred 字段(兼容旧数据库)
|
||||
try:
|
||||
cursor.execute("SELECT is_starred FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN is_starred INTEGER DEFAULT 0")
|
||||
|
||||
# 创建 is_starred 索引(字段添加后再创建)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_starred ON items(is_starred)")
|
||||
|
||||
# 检查并添加 views 字段(兼容旧数据库)
|
||||
try:
|
||||
cursor.execute("SELECT views FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN views INTEGER DEFAULT 0")
|
||||
|
||||
# 检查并添加 is_deleted 和 deleted_at 字段(兼容旧数据库)
|
||||
try:
|
||||
cursor.execute("SELECT is_deleted FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN is_deleted INTEGER DEFAULT 0")
|
||||
try:
|
||||
cursor.execute("SELECT deleted_at FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Item 操作 ============
|
||||
@@ -116,7 +170,7 @@ class Database:
|
||||
def create_item(self, type: str = "text", title: str = None, content: str = None,
|
||||
url: str = None, source: str = None, status: str = "pending",
|
||||
priority: str = "medium", due_date: str = None, note: str = None,
|
||||
tags: List[str] = None) -> int:
|
||||
tags: List[str] = None, is_starred: bool = False) -> int:
|
||||
"""创建新条目"""
|
||||
self._ensure_init()
|
||||
now = datetime.now().isoformat()
|
||||
@@ -130,9 +184,9 @@ class Database:
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (type, title, content, url, source, status, priority, due_date, note, now, now))
|
||||
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, now, now))
|
||||
item_id = cursor.lastrowid
|
||||
|
||||
# 添加标签
|
||||
@@ -156,8 +210,13 @@ class Database:
|
||||
return item
|
||||
|
||||
def list_items(self, type: str = None, status: str = None, tag: str = None,
|
||||
keyword: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出条目"""
|
||||
keyword: str = None, starred: bool = None, sort_by: str = None,
|
||||
sort_order: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出条目
|
||||
|
||||
sort_by: created_at, updated_at
|
||||
sort_order: desc, asc
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -165,6 +224,81 @@ class Database:
|
||||
params = []
|
||||
conditions = []
|
||||
|
||||
# 只显示未删除的数据
|
||||
conditions.append("i.is_deleted = 0")
|
||||
|
||||
# 标签过滤需要JOIN
|
||||
if tag:
|
||||
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
|
||||
conditions.append("t.name = ?")
|
||||
params.append(tag)
|
||||
|
||||
if type:
|
||||
conditions.append("i.type = ?")
|
||||
params.append(type)
|
||||
|
||||
if status:
|
||||
conditions.append("i.status = ?")
|
||||
params.append(status)
|
||||
|
||||
if starred is not None:
|
||||
conditions.append("i.is_starred = ?")
|
||||
params.append(1 if starred else 0)
|
||||
|
||||
if keyword:
|
||||
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
params.extend([keyword_pattern, keyword_pattern, keyword_pattern])
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
# 排序逻辑
|
||||
if sort_by == 'updated_at':
|
||||
order_field = 'i.updated_at'
|
||||
elif sort_by == 'created_at':
|
||||
order_field = 'i.created_at'
|
||||
else:
|
||||
# 默认:重点关注优先 + 创建时间降序
|
||||
order_field = 'i.created_at'
|
||||
|
||||
order_dir = 'DESC' if (sort_order == 'asc' or sort_order is None) else 'ASC'
|
||||
# 这里反转逻辑:用户选择"降序"时用DESC,选择"升序"时用ASC
|
||||
|
||||
if sort_order == 'asc':
|
||||
order_dir = 'ASC'
|
||||
elif sort_order == 'desc':
|
||||
order_dir = 'DESC'
|
||||
else:
|
||||
order_dir = 'DESC' # 默认降序
|
||||
|
||||
# 如果有指定排序字段,按该字段排序;否则默认重点关注优先
|
||||
if sort_by:
|
||||
query += f" ORDER BY {order_field} {order_dir} LIMIT ? OFFSET ?"
|
||||
else:
|
||||
# 默认:重点关注优先,然后创建时间降序
|
||||
query += f" ORDER BY i.is_starred DESC, i.created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
items = []
|
||||
for row in cursor.fetchall():
|
||||
item = dict(row)
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
def count_items(self, type: str = None, status: str = None, tag: str = None,
|
||||
keyword: str = None, starred: bool = None) -> int:
|
||||
"""计算符合条件的条目总数"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT COUNT(DISTINCT i.id) as count FROM items i"
|
||||
params = []
|
||||
conditions = []
|
||||
|
||||
# 标签过滤需要JOIN
|
||||
if tag:
|
||||
query += " JOIN item_tags it ON i.id = it.item_id JOIN tags t ON it.tag_id = t.id"
|
||||
@@ -179,6 +313,10 @@ class Database:
|
||||
conditions.append("i.status = ?")
|
||||
params.append(status)
|
||||
|
||||
if starred is not None:
|
||||
conditions.append("i.is_starred = ?")
|
||||
params.append(1 if starred else 0)
|
||||
|
||||
if keyword:
|
||||
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
@@ -187,23 +325,15 @@ class Database:
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDER BY i.created_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
items = []
|
||||
for row in cursor.fetchall():
|
||||
item = dict(row)
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
return cursor.fetchone()['count']
|
||||
|
||||
def update_item(self, item_id: int, **kwargs) -> bool:
|
||||
"""更新条目"""
|
||||
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note']
|
||||
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'is_starred']
|
||||
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
||||
|
||||
# 只有 tags 变化也算有效更新
|
||||
if not update_fields and 'tags' not in kwargs:
|
||||
return False
|
||||
|
||||
@@ -212,6 +342,11 @@ class Database:
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查条目是否存在
|
||||
cursor.execute("SELECT id FROM items WHERE id = ?", (item_id,))
|
||||
if not cursor.fetchone():
|
||||
return False
|
||||
|
||||
if update_fields:
|
||||
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
|
||||
set_clause += ", updated_at = ?"
|
||||
@@ -226,16 +361,212 @@ class Database:
|
||||
self._add_tags_to_item(conn, item_id, kwargs['tags'])
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
return True
|
||||
|
||||
def delete_item(self, item_id: int) -> bool:
|
||||
"""删除条目"""
|
||||
"""删除条目(移动到回收站)"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("UPDATE items SET is_deleted = 1, deleted_at = ? WHERE id = ?", (now, item_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_trash(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出回收站数据"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM items WHERE is_deleted = 1 ORDER BY deleted_at DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||
items = []
|
||||
for row in cursor.fetchall():
|
||||
item = dict(row)
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def count_trash(self) -> int:
|
||||
"""计算回收站数据总数"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count FROM items WHERE is_deleted = 1")
|
||||
return cursor.fetchone()['count']
|
||||
|
||||
def restore_item(self, item_id: int) -> bool:
|
||||
"""从回收站恢复数据"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE items SET is_deleted = 0, deleted_at = NULL WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_permanently(self, item_id: int) -> bool:
|
||||
"""彻底删除数据(从数据库中删除)"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 删除标签关联
|
||||
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||
# 删除邮件发送记录
|
||||
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||
# 删除数据
|
||||
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def empty_trash(self) -> int:
|
||||
"""清空回收站"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 获取所有回收站数据ID
|
||||
cursor.execute("SELECT id FROM items WHERE is_deleted = 1")
|
||||
ids = [row['id'] for row in cursor.fetchall()]
|
||||
|
||||
# 删除所有关联数据
|
||||
for item_id in ids:
|
||||
cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,))
|
||||
cursor.execute("DELETE FROM email_logs WHERE item_id = ?", (item_id,))
|
||||
|
||||
# 删除所有回收站数据
|
||||
cursor.execute("DELETE FROM items WHERE is_deleted = 1")
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted_count
|
||||
|
||||
def toggle_star(self, item_id: int) -> bool:
|
||||
"""切换重点关注状态"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 先获取当前状态
|
||||
cursor.execute("SELECT is_starred FROM items WHERE id = ?", (item_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
|
||||
new_status = 0 if row['is_starred'] else 1
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (new_status, now, item_id))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def set_star(self, item_id: int, starred: bool = True) -> bool:
|
||||
"""设置重点关注状态"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("UPDATE items SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if starred else 0, now, item_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def increment_views(self, item_id: int) -> bool:
|
||||
"""增加阅读数"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE items SET views = views + 1 WHERE id = ?", (item_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# ============ Draft 草稿操作 ============
|
||||
|
||||
def save_draft(self, type: str = "text", title: str = None, content: str = None,
|
||||
url: str = None, source: str = None, status: str = "pending",
|
||||
priority: str = "medium", due_date: str = None, note: str = None,
|
||||
tags: str = None, is_starred: bool = False) -> int:
|
||||
"""保存草稿"""
|
||||
self._ensure_init()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO drafts (type, title, content, url, source, status, priority, due_date, note, tags, is_starred, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (type, title, content, url, source, status, priority, due_date, note, tags, 1 if is_starred else 0, now, now))
|
||||
draft_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
return draft_id
|
||||
|
||||
def update_draft(self, draft_id: int, **kwargs) -> bool:
|
||||
"""更新草稿"""
|
||||
allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note', 'tags', 'is_starred']
|
||||
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
||||
|
||||
if not update_fields:
|
||||
return False
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM drafts WHERE id = ?", (draft_id,))
|
||||
if not cursor.fetchone():
|
||||
return False
|
||||
|
||||
set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys())
|
||||
set_clause += ", updated_at = ?"
|
||||
values = list(update_fields.values()) + [now, draft_id]
|
||||
cursor.execute(f"UPDATE drafts SET {set_clause} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
def list_drafts(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""列出草稿"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM drafts ORDER BY updated_at DESC LIMIT ? OFFSET ?", (limit, offset))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def count_drafts(self) -> int:
|
||||
"""计算草稿总数"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count FROM drafts")
|
||||
return cursor.fetchone()['count']
|
||||
|
||||
def get_draft(self, draft_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""获取单个草稿"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM drafts WHERE id = ?", (draft_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_draft(self, draft_id: int) -> bool:
|
||||
"""删除草稿"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM drafts WHERE id = ?", (draft_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def draft_to_item(self, draft_id: int) -> Optional[int]:
|
||||
"""将草稿转为正式条目"""
|
||||
draft = self.get_draft(draft_id)
|
||||
if not draft:
|
||||
return None
|
||||
|
||||
# 创建条目
|
||||
tags_list = draft['tags'].split(',') if draft['tags'] else []
|
||||
tags_list = [t.strip() for t in tags_list if t.strip()]
|
||||
|
||||
item_id = self.create_item(
|
||||
type=draft['type'],
|
||||
title=draft['title'],
|
||||
content=draft['content'],
|
||||
url=draft['url'],
|
||||
source=draft['source'],
|
||||
status=draft['status'],
|
||||
priority=draft['priority'],
|
||||
due_date=draft['due_date'],
|
||||
note=draft['note'],
|
||||
tags=tags_list,
|
||||
is_starred=draft['is_starred']
|
||||
)
|
||||
|
||||
# 删除草稿
|
||||
if item_id:
|
||||
self.delete_draft(draft_id)
|
||||
|
||||
return item_id
|
||||
|
||||
# ============ Tag 操作 ============
|
||||
|
||||
def create_tag(self, name: str, color: str = "#3498db") -> int:
|
||||
@@ -464,21 +795,36 @@ class Database:
|
||||
item['tags'] = self._get_item_tags(conn, item['id'])
|
||||
|
||||
try:
|
||||
due_date = datetime.strptime(item['due_date'], '%Y-%m-%d')
|
||||
# 计算距离到期的时间
|
||||
days_left = (due_date.date() - now.date()).days
|
||||
due_date_str = item['due_date']
|
||||
# 支持多种日期格式
|
||||
if 'T' in due_date_str:
|
||||
# ISO 格式:2026-04-16T14:30
|
||||
due_date = datetime.strptime(due_date_str[:16], '%Y-%m-%dT%H:%M')
|
||||
elif len(due_date_str) == 10:
|
||||
# 只有日期:2026-04-16,视为当天 23:59:59
|
||||
due_date = datetime.strptime(due_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
||||
else:
|
||||
# 其他格式,尝试解析
|
||||
due_date = datetime.strptime(due_date_str.split('.')[0], '%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
if days_left < 0:
|
||||
# 计算距离到期的时间
|
||||
time_left = due_date - now
|
||||
|
||||
if time_left.total_seconds() < 0:
|
||||
# 已过期
|
||||
item['days_overdue'] = abs(days_left)
|
||||
days_overdue = abs(int(time_left.total_seconds() / 86400))
|
||||
item['days_overdue'] = days_overdue
|
||||
reminders['overdue'].append(item)
|
||||
elif days_left == 0:
|
||||
# 今天到期
|
||||
elif time_left.total_seconds() < 86400: # 24小时内
|
||||
# 判断是今天还是明天
|
||||
if due_date.date() == now.date():
|
||||
reminders['due_today'].append(item)
|
||||
else:
|
||||
reminders['due_soon'].append(item)
|
||||
elif due_date.date() == now.date():
|
||||
# 今天到期(超过24小时的情况,比如现在凌晨,截止时间是晚上)
|
||||
reminders['due_today'].append(item)
|
||||
elif days_left == 1:
|
||||
# 明天到期(24小时内)
|
||||
reminders['due_soon'].append(item)
|
||||
except ValueError:
|
||||
except (ValueError, AttributeError) as e:
|
||||
# 日期格式错误,跳过
|
||||
continue
|
||||
|
||||
@@ -486,6 +832,184 @@ class Database:
|
||||
reminders['total'] = len(reminders['overdue']) + len(reminders['due_today']) + len(reminders['due_soon'])
|
||||
|
||||
return reminders
|
||||
|
||||
# ============ 备份操作 ============
|
||||
|
||||
def create_backup(self, manual: bool = False) -> Dict[str, Any]:
|
||||
"""创建数据库备份"""
|
||||
import os
|
||||
|
||||
# 确保备份目录存在
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
|
||||
now = datetime.now()
|
||||
backup_name = now.strftime('%Y-%m-%d_%H%M%S')
|
||||
if manual:
|
||||
backup_name += '_manual'
|
||||
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
|
||||
|
||||
# 复制数据库文件
|
||||
shutil.copy2(self.db_path, backup_path)
|
||||
|
||||
# 获取备份信息
|
||||
backup_info = {
|
||||
'name': backup_name,
|
||||
'path': backup_path,
|
||||
'size': os.path.getsize(backup_path),
|
||||
'created_at': now.isoformat(),
|
||||
'manual': manual,
|
||||
'is_first_of_month': now.day == 1
|
||||
}
|
||||
|
||||
# 保存备份元数据
|
||||
self._save_backup_meta(backup_info)
|
||||
|
||||
# 清理旧备份
|
||||
self._cleanup_old_backups()
|
||||
|
||||
return backup_info
|
||||
|
||||
def list_backups(self) -> List[Dict[str, Any]]:
|
||||
"""列出所有备份"""
|
||||
import os
|
||||
|
||||
if not os.path.exists(BACKUP_DIR):
|
||||
return []
|
||||
|
||||
# 读取备份元数据
|
||||
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
|
||||
if os.path.exists(meta_path):
|
||||
with open(meta_path, 'r') as f:
|
||||
backups = json.load(f)
|
||||
else:
|
||||
# 从文件重建元数据
|
||||
backups = []
|
||||
for f in os.listdir(BACKUP_DIR):
|
||||
if f.endswith('.db'):
|
||||
path = os.path.join(BACKUP_DIR, f)
|
||||
backups.append({
|
||||
'name': f.replace('.db', ''),
|
||||
'path': path,
|
||||
'size': os.path.getsize(path),
|
||||
'created_at': datetime.fromtimestamp(os.path.getmtime(path)).isoformat(),
|
||||
'manual': '_manual' in f,
|
||||
'is_first_of_month': self._is_first_of_month_filename(f)
|
||||
})
|
||||
|
||||
# 按时间倒序排列
|
||||
backups.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
def restore_backup(self, backup_name: str) -> bool:
|
||||
"""恢复备份"""
|
||||
import os
|
||||
|
||||
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
|
||||
if not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
# 先备份当前数据库(以防万一)
|
||||
current_backup = self.db_path + '.before_restore'
|
||||
shutil.copy2(self.db_path, current_backup)
|
||||
|
||||
# 恢复备份
|
||||
shutil.copy2(backup_path, self.db_path)
|
||||
|
||||
return True
|
||||
|
||||
def delete_backup(self, backup_name: str) -> bool:
|
||||
"""删除备份"""
|
||||
import os
|
||||
|
||||
backup_path = os.path.join(BACKUP_DIR, f'{backup_name}.db')
|
||||
if not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
os.remove(backup_path)
|
||||
|
||||
# 更新元数据
|
||||
self._remove_backup_meta(backup_name)
|
||||
|
||||
return True
|
||||
|
||||
def _save_backup_meta(self, backup_info: Dict[str, Any]):
|
||||
"""保存备份元数据"""
|
||||
import os
|
||||
|
||||
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
|
||||
|
||||
# 读取现有元数据
|
||||
backups = []
|
||||
if os.path.exists(meta_path):
|
||||
with open(meta_path, 'r') as f:
|
||||
backups = json.load(f)
|
||||
|
||||
# 添加新备份
|
||||
backups.append(backup_info)
|
||||
|
||||
# 保存
|
||||
with open(meta_path, 'w') as f:
|
||||
json.dump(backups, f, indent=2)
|
||||
|
||||
def _remove_backup_meta(self, backup_name: str):
|
||||
"""从元数据中删除备份"""
|
||||
import os
|
||||
|
||||
meta_path = os.path.join(BACKUP_DIR, 'backup_meta.json')
|
||||
if not os.path.exists(meta_path):
|
||||
return
|
||||
|
||||
with open(meta_path, 'r') as f:
|
||||
backups = json.load(f)
|
||||
|
||||
backups = [b for b in backups if b['name'] != backup_name]
|
||||
|
||||
with open(meta_path, 'w') as f:
|
||||
json.dump(backups, f, indent=2)
|
||||
|
||||
def _is_first_of_month_filename(self, filename: str) -> bool:
|
||||
"""判断是否是每月第一天的备份"""
|
||||
# 格式:2026-04-01_040000.db 或 2026-05-01_...
|
||||
try:
|
||||
date_part = filename.split('_')[0]
|
||||
day = int(date_part.split('-')[2])
|
||||
return day == 1
|
||||
except:
|
||||
return False
|
||||
|
||||
def _cleanup_old_backups(self):
|
||||
"""清理旧备份:保留30天 + 每月第一天"""
|
||||
import os
|
||||
|
||||
backups = self.list_backups()
|
||||
now = datetime.now()
|
||||
keep_paths = []
|
||||
|
||||
for backup in backups:
|
||||
backup_date = datetime.fromisoformat(backup['created_at'])
|
||||
days_old = (now - backup_date).days
|
||||
|
||||
# 保留条件:
|
||||
# 1. 手动备份永久保留(最多10个)
|
||||
# 2. 30天内的备份
|
||||
# 3. 每月第一天的备份
|
||||
|
||||
if backup['manual']:
|
||||
# 手动备份保留,但最多10个
|
||||
manual_backups = [b for b in backups if b['manual']]
|
||||
if manual_backups.index(backup) < 10:
|
||||
keep_paths.append(backup['path'])
|
||||
else:
|
||||
self.delete_backup(backup['name'])
|
||||
elif days_old <= 30:
|
||||
keep_paths.append(backup['path'])
|
||||
elif backup['is_first_of_month']:
|
||||
# 每月第一天永久保留
|
||||
keep_paths.append(backup['path'])
|
||||
else:
|
||||
# 删除
|
||||
self.delete_backup(backup['name'])
|
||||
|
||||
|
||||
# 全局数据库实例
|
||||
|
||||
Reference in New Issue
Block a user