commit 184cc5b56b270d02d52feb179675986b4527007e Author: hubian <908234780@qq.com> Date: Sun Apr 12 01:34:13 2026 +0800 feat: 收藏关注系统 v1.0.0 - 支持CLI/API/Web三种操作模式 diff --git a/README.md b/README.md new file mode 100644 index 0000000..84c29cf --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Xian Favor - 收藏关注系统 + +一个灵活的收藏管理系统,支持文本、链接、专栏、待办等多种内容类型,提供命令行、API、Web三种操作方式。 + +## 功能特性 + +- **多种内容类型**: 文本片段、网址链接、个人专栏、待办事项 +- **标签系统**: 灵活的标签分类,支持多标签 +- **状态管理**: 待办事项支持待处理/进行中/已完成状态 +- **优先级**: 待办事项支持低/中/高/紧急优先级 +- **截止日期**: 待办事项支持设置截止日期 +- **全文搜索**: 快速搜索标题、内容、备注 +- **统计面板**: 实时统计各类型、状态数量 + +## 安装 + +```bash +cd works/xian-favor +pip install -e . +``` + +## 使用方式 + +### 命令行 (CLI) + +```bash +# 添加文本笔记 +xian_favor add text "这是我的笔记" -t "笔记,重要" + +# 添加链接 +xian_favor add link "https://example.com" --title "示例网站" -t "技术" + +# 添加专栏 +xian_favor add column "https://column.example.com/feed" --title "技术专栏" --source "RSS" -t "订阅" + +# 添加待办(高优先级,截止日期) +xian_favor add todo "完成项目" -p high -d "2026-12-31" -t "工作" + +# 列出所有条目 +xian_favor list + +# 筛选:只看待办 +xian_favor list --type todo + +# 筛选:只看进行中的待办 +xian_favor list --type todo --status in_progress + +# 筛选:按标签 +xian_favor list --tag 技术 + +# 搜索 +xian_favor search "关键词" + +# 查看详情 +xian_favor show 1 + +# 编辑 +xian_favor edit 1 --status completed --note "已完成" + +# 完成待办(快捷命令) +xian_favor done 1 + +# 删除 +xian_favor delete 1 + +# 标签管理 +xian_favor tags +xian_favor tags --delete "旧标签" + +# 统计信息 +xian_favor stats + +# 启动API服务 +xian_favor serve --port 19014 +``` + +### API 服务 + +启动服务: +```bash +xian_favor serve +# 或指定端口 +xian_favor serve --port 19014 +``` + +API端点: + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/items` | GET | 列出条目 (支持 type/status/tag/keyword 参数) | +| `/api/items` | POST | 创建条目 | +| `/api/items/` | GET | 获取详情 | +| `/api/items/` | PUT | 更新条目 | +| `/api/items/` | DELETE | 删除条目 | +| `/api/items//done` | POST | 完成待办 | +| `/api/tags` | GET | 列出标签 | +| `/api/tags` | POST | 创建标签 | +| `/api/tags/` | DELETE | 删除标签 | +| `/api/stats` | GET | 统计信息 | +| `/api/search` | GET | 搜索 (参数 q=关键词) | + +### Web 界面 + +访问 `http://localhost:19014` 即可使用Web界面: + +- 侧边栏快速筛选(类型、状态) +- 搜索框实时搜索 +- 统计卡片实时更新 +- 添加/编辑/删除操作 +- 一键完成待办 + +## 数据存储 + +数据保存在 `data/xian_favor.db` (SQLite数据库) + +## 端口 + +- 默认端口: **19014** + +## 项目结构 + +``` +xian-favor/ +├── xian_favor/ +│ ├── __init__.py +│ ├── cli.py # 命令行工具 +│ ├── api.py # API + Web服务 +│ ├── db.py # 数据库操作 +│ └── config.py # 配置 +├── data/ +│ └── xian_favor.db # 数据库 +├── pyproject.toml +├── requirements.txt +└── README.md +``` + +## 版本历史 + +- v1.0.0 (2026-04-12): 初始版本,支持CLI/API/Web三种模式 \ No newline at end of file diff --git a/UNKNOWN.egg-info/PKG-INFO b/UNKNOWN.egg-info/PKG-INFO new file mode 100644 index 0000000..405ec6f --- /dev/null +++ b/UNKNOWN.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.1 +Name: UNKNOWN +Version: 0.0.0 +Summary: UNKNOWN +Home-page: UNKNOWN +License: UNKNOWN +Platform: UNKNOWN + +UNKNOWN + diff --git a/UNKNOWN.egg-info/SOURCES.txt b/UNKNOWN.egg-info/SOURCES.txt new file mode 100644 index 0000000..bb52b66 --- /dev/null +++ b/UNKNOWN.egg-info/SOURCES.txt @@ -0,0 +1,7 @@ +README.md +pyproject.toml +setup.py +UNKNOWN.egg-info/PKG-INFO +UNKNOWN.egg-info/SOURCES.txt +UNKNOWN.egg-info/dependency_links.txt +UNKNOWN.egg-info/top_level.txt \ No newline at end of file diff --git a/UNKNOWN.egg-info/dependency_links.txt b/UNKNOWN.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/UNKNOWN.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/UNKNOWN.egg-info/top_level.txt b/UNKNOWN.egg-info/top_level.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/UNKNOWN.egg-info/top_level.txt @@ -0,0 +1 @@ + diff --git a/build/lib/xian_favor/__init__.py b/build/lib/xian_favor/__init__.py new file mode 100644 index 0000000..a7650fa --- /dev/null +++ b/build/lib/xian_favor/__init__.py @@ -0,0 +1,6 @@ +""" +Xian Favor - 收藏关注系统 +支持命令行、API、Web多种操作模式 +""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/build/lib/xian_favor/__main__.py b/build/lib/xian_favor/__main__.py new file mode 100644 index 0000000..51a6bad --- /dev/null +++ b/build/lib/xian_favor/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""命令行入口""" + +from xian_favor.cli import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/build/lib/xian_favor/api.py b/build/lib/xian_favor/api.py new file mode 100644 index 0000000..2e0a3ac --- /dev/null +++ b/build/lib/xian_favor/api.py @@ -0,0 +1,567 @@ +"""API服务""" + +from flask import Flask, request, jsonify, render_template_string +from flask_cors import CORS +import os + +from .db import db +from .config import API_HOST, API_PORT, ITEM_TYPES, TODO_STATUS, PRIORITY_LEVELS + +app = Flask(__name__, + template_folder=os.path.join(os.path.dirname(__file__), '../web/templates'), + static_folder=os.path.join(os.path.dirname(__file__), '../web/static')) +CORS(app) + +# ============ API 路由 ============ + +@app.route('/api/items', methods=['GET']) +def list_items(): + """列出条目""" + items = db.list_items( + type=request.args.get('type'), + status=request.args.get('status'), + tag=request.args.get('tag'), + keyword=request.args.get('keyword'), + limit=int(request.args.get('limit', 50)), + offset=int(request.args.get('offset', 0)) + ) + return jsonify({'success': True, 'data': items}) + + +@app.route('/api/items', methods=['POST']) +def create_item(): + """创建条目""" + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '无数据'}), 400 + + item_type = data.get('type', 'text') + if item_type not in ITEM_TYPES: + return jsonify({'success': False, 'error': f'无效类型: {item_type}'}), 400 + + try: + item_id = db.create_item( + type=item_type, + title=data.get('title'), + content=data.get('content'), + url=data.get('url'), + source=data.get('source'), + status=data.get('status', 'pending'), + priority=data.get('priority', 'medium'), + due_date=data.get('due_date'), + note=data.get('note'), + tags=data.get('tags', []) + ) + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}), 201 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/items/', methods=['GET']) +def get_item(item_id): + """获取条目""" + item = db.get_item(item_id) + if not item: + return jsonify({'success': False, 'error': '条目不存在'}), 404 + return jsonify({'success': True, 'data': item}) + + +@app.route('/api/items/', methods=['PUT']) +def update_item(item_id): + """更新条目""" + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '无数据'}), 400 + + try: + if db.update_item(item_id, **data): + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}) + else: + return jsonify({'success': False, 'error': '条目不存在或无变化'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/items/', methods=['DELETE']) +def delete_item(item_id): + """删除条目""" + if db.delete_item(item_id): + return jsonify({'success': True}) + return jsonify({'success': False, 'error': '条目不存在'}), 404 + + +@app.route('/api/items//done', methods=['POST']) +def complete_item(item_id): + """完成待办""" + item = db.get_item(item_id) + if not item: + return jsonify({'success': False, 'error': '条目不存在'}), 404 + + if item['type'] != 'todo': + return jsonify({'success': False, 'error': '不是待办事项'}), 400 + + db.update_item(item_id, status='completed') + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}) + + +@app.route('/api/tags', methods=['GET']) +def list_tags(): + """列出标签""" + tags = db.list_tags() + return jsonify({'success': True, 'data': tags}) + + +@app.route('/api/tags', methods=['POST']) +def create_tag(): + """创建标签""" + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'success': False, 'error': '标签名不能为空'}), 400 + + try: + tag_id = db.create_tag(name, data.get('color', '#3498db')) + return jsonify({'success': True, 'data': {'id': tag_id, 'name': name}}), 201 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/tags/', methods=['DELETE']) +def delete_tag(tag_id): + """删除标签""" + if db.delete_tag(tag_id=tag_id): + return jsonify({'success': True}) + return jsonify({'success': False, 'error': '标签不存在'}), 404 + + +@app.route('/api/stats', methods=['GET']) +def get_stats(): + """获取统计""" + stats = db.stats() + return jsonify({'success': True, 'data': stats}) + + +@app.route('/api/search', methods=['GET']) +def search_items(): + """搜索条目""" + keyword = request.args.get('q', '') + if not keyword: + return jsonify({'success': False, 'error': '请提供搜索关键词'}), 400 + + items = db.list_items( + keyword=keyword, + type=request.args.get('type'), + limit=int(request.args.get('limit', 50)) + ) + return jsonify({'success': True, 'data': items}) + + +# ============ Web 页面 ============ + +@app.route('/') +def index(): + """主页""" + return render_template_string(INDEX_TEMPLATE) + + +# ============ Web 模板 ============ + +INDEX_TEMPLATE = ''' + + + + + + Xian Favor - 收藏系统 + + + + + +
+
+ + + + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+
总条目
+

0

+
+
+
+
+
+
+
待处理
+

0

+
+
+
+
+
+
+
进行中
+

0

+
+
+
+
+
+
+
已完成
+

0

+
+
+
+
+ + +
+
+
+
+ + + + + + + + +''' + + +def start_server(host: str = API_HOST, port: int = API_PORT): + """启动服务""" + app.run(host=host, port=port, debug=False) \ No newline at end of file diff --git a/build/lib/xian_favor/cli.py b/build/lib/xian_favor/cli.py new file mode 100644 index 0000000..6eda465 --- /dev/null +++ b/build/lib/xian_favor/cli.py @@ -0,0 +1,430 @@ +"""命令行工具""" + +import argparse +import sys +import json +from datetime import datetime +from typing import List + +from .db import db +from .config import ITEM_TYPES, TODO_STATUS, PRIORITY_LEVELS + + +def main(): + parser = argparse.ArgumentParser( + description="Xian Favor - 收藏关注系统", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + xian_favor add text "这是我的笔记" -t "笔记,重要" + xian_favor add link "https://example.com" --title "示例网站" -t "技术" + xian_favor add todo "完成项目" -p high -d "2024-12-31" -t "工作" + xian_favor add column "https://column.example.com/feed" --title "技术专栏" -t "RSS" + xian_favor list + xian_favor list --type todo --status pending + xian_favor search "关键词" + xian_favor show 1 + xian_favor edit 1 --status completed --note "已完成" + xian_favor done 1 + xian_favor delete 1 + xian_favor tags + xian_favor stats + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="命令") + + # ============ add 命令 ============ + add_parser = subparsers.add_parser("add", help="添加新条目") + add_parser.add_argument("type", choices=ITEM_TYPES, help="类型: text/link/column/todo") + add_parser.add_argument("content", help="内容或URL") + add_parser.add_argument("--title", "-T", help="标题") + add_parser.add_argument("--url", "-u", help="URL (link/column类型)") + add_parser.add_argument("--source", "-s", help="来源") + add_parser.add_argument("--status", choices=TODO_STATUS, default="pending", help="状态 (todo)") + add_parser.add_argument("--priority", "-p", choices=PRIORITY_LEVELS, default="medium", help="优先级") + add_parser.add_argument("--due-date", "-d", help="截止日期 (YYYY-MM-DD)") + add_parser.add_argument("--note", "-n", help="备注") + add_parser.add_argument("--tags", "-t", help="标签 (逗号分隔)") + + # ============ list 命令 ============ + list_parser = subparsers.add_parser("list", help="列出条目") + list_parser.add_argument("--type", choices=ITEM_TYPES, help="类型过滤") + list_parser.add_argument("--status", choices=TODO_STATUS, help="状态过滤") + list_parser.add_argument("--tag", help="标签过滤") + list_parser.add_argument("--limit", "-l", type=int, default=20, help="数量限制") + list_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ show 命令 ============ + show_parser = subparsers.add_parser("show", help="查看详情") + show_parser.add_argument("id", type=int, help="条目ID") + show_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ edit 命令 ============ + edit_parser = subparsers.add_parser("edit", help="编辑条目") + edit_parser.add_argument("id", type=int, help="条目ID") + edit_parser.add_argument("--title", "-T", help="标题") + edit_parser.add_argument("--content", "-c", help="内容") + edit_parser.add_argument("--url", "-u", help="URL") + edit_parser.add_argument("--source", "-s", help="来源") + edit_parser.add_argument("--status", choices=TODO_STATUS, help="状态") + edit_parser.add_argument("--priority", "-p", choices=PRIORITY_LEVELS, help="优先级") + edit_parser.add_argument("--due-date", "-d", help="截止日期") + edit_parser.add_argument("--note", "-n", help="备注") + edit_parser.add_argument("--tags", "-t", help="标签 (逗号分隔, 覆盖)") + + # ============ done 命令 ============ + done_parser = subparsers.add_parser("done", help="完成待办") + done_parser.add_argument("id", type=int, help="条目ID") + + # ============ delete 命令 ============ + delete_parser = subparsers.add_parser("delete", help="删除条目") + delete_parser.add_argument("id", type=int, help="条目ID") + delete_parser.add_argument("-f", "--force", action="store_true", help="强制删除不确认") + + # ============ search 命令 ============ + search_parser = subparsers.add_parser("search", help="搜索条目") + search_parser.add_argument("keyword", help="关键词") + search_parser.add_argument("--type", choices=ITEM_TYPES, help="类型过滤") + search_parser.add_argument("--limit", "-l", type=int, default=20, help="数量限制") + search_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ tags 命令 ============ + tags_parser = subparsers.add_parser("tags", help="标签管理") + tags_parser.add_argument("--delete", "-d", help="删除标签") + + # ============ stats 命令 ============ + subparsers.add_parser("stats", help="统计信息") + + # ============ serve 命令 ============ + serve_parser = subparsers.add_parser("serve", help="启动API服务") + serve_parser.add_argument("--host", default="0.0.0.0", help="主机") + serve_parser.add_argument("--port", type=int, default=19014, help="端口") + + # 解析参数 + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + # 执行命令 + try: + if args.command == "add": + cmd_add(args) + elif args.command == "list": + cmd_list(args) + elif args.command == "show": + cmd_show(args) + elif args.command == "edit": + cmd_edit(args) + elif args.command == "done": + cmd_done(args) + elif args.command == "delete": + cmd_delete(args) + elif args.command == "search": + cmd_search(args) + elif args.command == "tags": + cmd_tags(args) + elif args.command == "stats": + cmd_stats(args) + elif args.command == "serve": + cmd_serve(args) + except Exception as e: + print(f"❌ 错误: {e}", file=sys.stderr) + sys.exit(1) + + +# ============ 命令实现 ============ + +def parse_tags(tags_str: str) -> List[str]: + """解析标签字符串""" + if not tags_str: + return [] + return [t.strip() for t in tags_str.split(",") if t.strip()] + + +def format_item(item: dict, brief: bool = True) -> str: + """格式化条目显示""" + type_icons = { + "text": "📝", + "link": "🔗", + "column": "📰", + "todo": "✅" + } + status_icons = { + "pending": "⏳", + "in_progress": "🔄", + "completed": "✅" + } + priority_icons = { + "low": "🟢", + "medium": "🟡", + "high": "🟠", + "urgent": "🔴" + } + + icon = type_icons.get(item['type'], "📄") + status = status_icons.get(item.get('status', ''), '') + priority = priority_icons.get(item.get('priority', ''), '') + + title = item.get('title') or item.get('content', '')[:50] + tags_str = f" [{', '.join(item['tags'])}]" if item.get('tags') else "" + + if brief: + return f" {icon} [{item['id']}] {title}{tags_str}" + else: + lines = [ + f"{icon} [{item['id']}] {title}", + f" 类型: {item['type']}", + ] + if item.get('content'): + lines.append(f" 内容: {item['content'][:200]}") + if item.get('url'): + lines.append(f" URL: {item['url']}") + if item.get('source'): + lines.append(f" 来源: {item['source']}") + if item['type'] == 'todo': + lines.append(f" 状态: {status} {item.get('status', 'pending')}") + lines.append(f" 优先级: {priority} {item.get('priority', 'medium')}") + if item.get('due_date'): + lines.append(f" 截止: {item['due_date']}") + if item.get('note'): + lines.append(f" 备注: {item['note']}") + if item.get('tags'): + lines.append(f" 标签: {', '.join(item['tags'])}") + lines.append(f" 创建: {item['created_at'][:19]}") + return "\n".join(lines) + + +def cmd_add(args): + """添加条目""" + tags = parse_tags(args.tags) + + # 根据类型处理内容 + content = args.content + url = args.url + + if args.type == "link": + url = url or args.content + content = None + elif args.type == "column": + url = url or args.content + content = None + + item_id = db.create_item( + type=args.type, + title=args.title, + content=content, + url=url, + source=args.source, + status=args.status, + priority=args.priority, + due_date=args.due_date, + note=args.note, + tags=tags + ) + + item = db.get_item(item_id) + print(f"✅ 创建成功 (ID: {item_id})") + print(format_item(item, brief=False)) + + +def cmd_list(args): + """列出条目""" + items = db.list_items( + type=args.type, + status=args.status, + tag=args.tag, + limit=args.limit + ) + + if args.json: + print(json.dumps(items, ensure_ascii=False, indent=2)) + return + + if not items: + print("📭 没有找到条目") + return + + type_labels = {"text": "文本", "link": "链接", "column": "专栏", "todo": "待办"} + filter_desc = [] + if args.type: + filter_desc.append(f"类型: {type_labels.get(args.type, args.type)}") + if args.status: + filter_desc.append(f"状态: {args.status}") + if args.tag: + filter_desc.append(f"标签: {args.tag}") + + if filter_desc: + print(f"📋 筛选: {' | '.join(filter_desc)}") + else: + print(f"📋 全部条目 ({len(items)} 条)") + print("-" * 50) + + for item in items: + print(format_item(item)) + + +def cmd_show(args): + """查看详情""" + item = db.get_item(args.id) + + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + if args.json: + print(json.dumps(item, ensure_ascii=False, indent=2)) + return + + print(format_item(item, brief=False)) + + +def cmd_edit(args): + """编辑条目""" + update_data = {} + + if args.title is not None: + update_data['title'] = args.title + if args.content is not None: + update_data['content'] = args.content + if args.url is not None: + update_data['url'] = args.url + if args.source is not None: + update_data['source'] = args.source + if args.status is not None: + update_data['status'] = args.status + if args.priority is not None: + update_data['priority'] = args.priority + if args.due_date is not None: + update_data['due_date'] = args.due_date + if args.note is not None: + update_data['note'] = args.note + if args.tags is not None: + update_data['tags'] = parse_tags(args.tags) + + if not update_data: + print("❌ 没有指定要更新的字段") + return + + if db.update_item(args.id, **update_data): + item = db.get_item(args.id) + print(f"✅ 更新成功 (ID: {args.id})") + print(format_item(item, brief=False)) + else: + print(f"❌ 更新失败: 条目不存在或没有变化") + + +def cmd_done(args): + """完成待办""" + item = db.get_item(args.id) + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + if item['type'] != 'todo': + print(f"❌ 不是待办事项: {args.id}") + return + + if db.update_item(args.id, status='completed'): + print(f"✅ 已完成待办 (ID: {args.id})") + else: + print(f"❌ 操作失败") + + +def cmd_delete(args): + """删除条目""" + if not args.force: + item = db.get_item(args.id) + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + print(format_item(item, brief=False)) + confirm = input("确认删除? [y/N] ") + if confirm.lower() != 'y': + print("❌ 取消删除") + return + + if db.delete_item(args.id): + print(f"✅ 已删除 (ID: {args.id})") + else: + print(f"❌ 删除失败") + + +def cmd_search(args): + """搜索条目""" + items = db.list_items( + type=args.type, + keyword=args.keyword, + limit=args.limit + ) + + if args.json: + print(json.dumps(items, ensure_ascii=False, indent=2)) + return + + if not items: + print(f"🔍 没有找到匹配 '{args.keyword}' 的条目") + return + + print(f"🔍 搜索 '{args.keyword}' ({len(items)} 条)") + print("-" * 50) + + for item in items: + print(format_item(item)) + + +def cmd_tags(args): + """标签管理""" + if args.delete: + if db.delete_tag(name=args.delete): + print(f"✅ 已删除标签: {args.delete}") + else: + print(f"❌ 标签不存在: {args.delete}") + return + + tags = db.list_tags() + if not tags: + print("🏷️ 没有标签") + return + + print(f"🏷️ 标签列表 ({len(tags)} 个)") + print("-" * 50) + for tag in tags: + print(f" • {tag['name']} ({tag['item_count']} 条)") + + +def cmd_stats(args): + """统计信息""" + stats = db.stats() + + print("📊 收藏统计") + print("-" * 30) + print(f"总条目: {stats['total']}") + + if stats.get('by_type'): + print("\n按类型:") + type_labels = {"text": "文本", "link": "链接", "column": "专栏", "todo": "待办"} + for t, count in stats['by_type'].items(): + print(f" • {type_labels.get(t, t)}: {count}") + + if stats.get('todo_status'): + print("\n待办状态:") + status_labels = {"pending": "待处理", "in_progress": "进行中", "completed": "已完成"} + for s, count in stats['todo_status'].items(): + print(f" • {status_labels.get(s, s)}: {count}") + + print(f"\n标签数: {stats['tags']}") + + +def cmd_serve(args): + """启动API服务""" + from .api import start_server + print(f"🚀 启动API服务: http://{args.host}:{args.port}") + start_server(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/build/lib/xian_favor/config.py b/build/lib/xian_favor/config.py new file mode 100644 index 0000000..07d2e6e --- /dev/null +++ b/build/lib/xian_favor/config.py @@ -0,0 +1,26 @@ +"""配置文件""" + +import os +from pathlib import Path + +# 数据目录 - 使用用户可访问的路径 +# 默认在 ~/.xian_favor/ 目录下 +DEFAULT_DATA_DIR = Path.home() / ".xian_favor" +DATA_DIR = Path(os.getenv("XIAN_FAVOR_DATA_DIR", str(DEFAULT_DATA_DIR))) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +# 数据库 +DATABASE_URL = os.getenv("XIAN_FAVOR_DB", str(DATA_DIR / "xian_favor.db")) + +# API服务 +API_HOST = os.getenv("XIAN_FAVOR_HOST", "0.0.0.0") +API_PORT = int(os.getenv("XIAN_FAVOR_PORT", "19014")) + +# 内容类型 +ITEM_TYPES = ["text", "link", "column", "todo"] + +# 待办状态 +TODO_STATUS = ["pending", "in_progress", "completed"] + +# 优先级 +PRIORITY_LEVELS = ["low", "medium", "high", "urgent"] \ No newline at end of file diff --git a/build/lib/xian_favor/db.py b/build/lib/xian_favor/db.py new file mode 100644 index 0000000..990e3ea --- /dev/null +++ b/build/lib/xian_favor/db.py @@ -0,0 +1,321 @@ +"""数据库操作""" + +import sqlite3 +import json +from datetime import datetime +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +from .config import DATABASE_URL, TODO_STATUS, PRIORITY_LEVELS + + +class Database: + """SQLite数据库管理""" + + def __init__(self, db_path: str = DATABASE_URL): + self.db_path = db_path + self._initialized = False + + def _ensure_init(self): + """确保数据库已初始化""" + if self._initialized: + return + self._init_db() + self._initialized = True + + @contextmanager + def get_conn(self): + """获取数据库连接""" + conn = sqlite3.connect(self.db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + # 启用WAL模式,提高并发性能 + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + try: + yield conn + finally: + conn.close() + + def _init_db(self): + """初始化数据库表""" + with self.get_conn() as conn: + cursor = conn.cursor() + + # 主内容表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS items ( + 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, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + # 标签表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT DEFAULT '#3498db', + created_at TEXT NOT NULL + ) + """) + + # 内容-标签关联表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS item_tags ( + item_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (item_id, tag_id), + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ) + """) + + # 创建索引 + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_status ON items(status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_created ON items(created_at)") + 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)") + + conn.commit() + + # ============ Item 操作 ============ + + 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: + """创建新条目""" + self._ensure_init() + now = datetime.now().isoformat() + + # 验证状态 + if type == "todo" and status not in TODO_STATUS: + status = "pending" + if priority not in PRIORITY_LEVELS: + priority = "medium" + + 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)) + item_id = cursor.lastrowid + + # 添加标签 + if tags: + self._add_tags_to_item(conn, item_id, tags) + + conn.commit() + return item_id + + def get_item(self, item_id: int) -> Optional[Dict[str, Any]]: + """获取单个条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM items WHERE id = ?", (item_id,)) + row = cursor.fetchone() + if not row: + return None + + item = dict(row) + item['tags'] = self._get_item_tags(conn, item_id) + 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]]: + """列出条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + + query = "SELECT DISTINCT i.* 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" + 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 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) + + 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 + + def update_item(self, item_id: int, **kwargs) -> bool: + """更新条目""" + allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note'] + update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields} + + if not update_fields and 'tags' not in kwargs: + return False + + now = datetime.now().isoformat() + + with self.get_conn() as conn: + cursor = conn.cursor() + + if update_fields: + set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys()) + set_clause += ", updated_at = ?" + values = list(update_fields.values()) + [now, item_id] + cursor.execute(f"UPDATE items SET {set_clause} WHERE id = ?", values) + + if 'tags' in kwargs: + # 先删除旧标签关联 + cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,)) + # 添加新标签 + if kwargs['tags']: + self._add_tags_to_item(conn, item_id, kwargs['tags']) + + conn.commit() + return cursor.rowcount > 0 + + def delete_item(self, item_id: int) -> bool: + """删除条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM items WHERE id = ?", (item_id,)) + conn.commit() + return cursor.rowcount > 0 + + # ============ Tag 操作 ============ + + def create_tag(self, name: str, color: str = "#3498db") -> int: + """创建标签""" + now = datetime.now().isoformat() + with self.get_conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("INSERT INTO tags (name, color, created_at) VALUES (?, ?, ?)", + (name, color, now)) + conn.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + # 标签已存在 + cursor.execute("SELECT id FROM tags WHERE name = ?", (name,)) + return cursor.fetchone()['id'] + + def list_tags(self) -> List[Dict[str, Any]]: + """列出所有标签""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT t.*, COUNT(it.item_id) as item_count + FROM tags t + LEFT JOIN item_tags it ON t.id = it.tag_id + GROUP BY t.id + ORDER BY t.name + """) + return [dict(row) for row in cursor.fetchall()] + + def delete_tag(self, tag_id: int = None, name: str = None) -> bool: + """删除标签""" + with self.get_conn() as conn: + cursor = conn.cursor() + if name: + cursor.execute("DELETE FROM tags WHERE name = ?", (name,)) + elif tag_id: + cursor.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) + conn.commit() + return cursor.rowcount > 0 + + # ============ 辅助方法 ============ + + def _add_tags_to_item(self, conn, item_id: int, tags: List[str]): + """为条目添加标签""" + cursor = conn.cursor() + for tag_name in tags: + tag_name = tag_name.strip() + if not tag_name: + continue + # 确保标签存在 - 使用同一个连接 + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + row = cursor.fetchone() + if row: + tag_id = row['id'] + else: + # 创建新标签 + now = datetime.now().isoformat() + cursor.execute("INSERT INTO tags (name, color, created_at) VALUES (?, '#3498db', ?)", + (tag_name, now)) + tag_id = cursor.lastrowid + # 创建关联 + cursor.execute("INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)", + (item_id, tag_id)) + + def _get_item_tags(self, conn, item_id: int) -> List[str]: + """获取条目的标签""" + cursor = conn.cursor() + cursor.execute(""" + SELECT t.name FROM tags t + JOIN item_tags it ON t.id = it.tag_id + WHERE it.item_id = ? + ORDER BY t.name + """, (item_id,)) + return [row['name'] for row in cursor.fetchall()] + + def stats(self) -> Dict[str, Any]: + """获取统计信息""" + self._ensure_init() + with self.get_conn() as conn: + cursor = conn.cursor() + + stats = {} + + # 总数 + cursor.execute("SELECT COUNT(*) as count FROM items") + stats['total'] = cursor.fetchone()['count'] + + # 按类型统计 + cursor.execute("SELECT type, COUNT(*) as count FROM items GROUP BY type") + stats['by_type'] = {row['type']: row['count'] for row in cursor.fetchall()} + + # 待办状态统计 + cursor.execute("SELECT status, COUNT(*) as count FROM items WHERE type = 'todo' GROUP BY status") + stats['todo_status'] = {row['status']: row['count'] for row in cursor.fetchall()} + + # 标签数 + cursor.execute("SELECT COUNT(*) as count FROM tags") + stats['tags'] = cursor.fetchone()['count'] + + return stats + + +# 全局数据库实例 +db = Database() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a5bb00a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "xian-favor" +version = "1.0.0" +description = "Xian Favor - 收藏关注系统,支持命令行、API、Web多种操作模式" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Xian", email = "wlq@tphai.com"} +] +dependencies = [ + "flask>=2.3.0", + "flask-cors>=4.0.0", +] + +[project.scripts] +xian_favor = "xian_favor.cli:main" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["xian_favor*"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a6bcb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=2.3.0 +flask-cors>=4.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b14743f --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Setup""" + +from setuptools import setup, find_packages + +setup( + name="xian-favor", + version="1.0.0", + description="Xian Favor - 收藏关注系统", + packages=find_packages(), + install_requires=[ + "flask>=2.3.0", + "flask-cors>=4.0.0", + ], + entry_points={ + "console_scripts": [ + "xian_favor=xian_favor.cli:main", + ], + }, + python_requires=">=3.8", +) \ No newline at end of file diff --git a/xian_favor.egg-info/PKG-INFO b/xian_favor.egg-info/PKG-INFO new file mode 100644 index 0000000..4685d19 --- /dev/null +++ b/xian_favor.egg-info/PKG-INFO @@ -0,0 +1,11 @@ +Metadata-Version: 2.1 +Name: xian-favor +Version: 1.0.0 +Summary: Xian Favor - 收藏关注系统 +Home-page: UNKNOWN +License: UNKNOWN +Platform: UNKNOWN +Requires-Python: >=3.8 + +UNKNOWN + diff --git a/xian_favor.egg-info/SOURCES.txt b/xian_favor.egg-info/SOURCES.txt new file mode 100644 index 0000000..5bae9c4 --- /dev/null +++ b/xian_favor.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +README.md +pyproject.toml +setup.py +xian_favor/__init__.py +xian_favor/__main__.py +xian_favor/api.py +xian_favor/cli.py +xian_favor/config.py +xian_favor/db.py +xian_favor.egg-info/PKG-INFO +xian_favor.egg-info/SOURCES.txt +xian_favor.egg-info/dependency_links.txt +xian_favor.egg-info/entry_points.txt +xian_favor.egg-info/requires.txt +xian_favor.egg-info/top_level.txt \ No newline at end of file diff --git a/xian_favor.egg-info/dependency_links.txt b/xian_favor.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/xian_favor.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/xian_favor.egg-info/entry_points.txt b/xian_favor.egg-info/entry_points.txt new file mode 100644 index 0000000..893b94c --- /dev/null +++ b/xian_favor.egg-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +xian_favor = xian_favor.cli:main + diff --git a/xian_favor.egg-info/requires.txt b/xian_favor.egg-info/requires.txt new file mode 100644 index 0000000..977cb43 --- /dev/null +++ b/xian_favor.egg-info/requires.txt @@ -0,0 +1,2 @@ +flask-cors>=4.0.0 +flask>=2.3.0 diff --git a/xian_favor.egg-info/top_level.txt b/xian_favor.egg-info/top_level.txt new file mode 100644 index 0000000..4ef6413 --- /dev/null +++ b/xian_favor.egg-info/top_level.txt @@ -0,0 +1 @@ +xian_favor diff --git a/xian_favor/__init__.py b/xian_favor/__init__.py new file mode 100644 index 0000000..a7650fa --- /dev/null +++ b/xian_favor/__init__.py @@ -0,0 +1,6 @@ +""" +Xian Favor - 收藏关注系统 +支持命令行、API、Web多种操作模式 +""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/xian_favor/__main__.py b/xian_favor/__main__.py new file mode 100644 index 0000000..51a6bad --- /dev/null +++ b/xian_favor/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""命令行入口""" + +from xian_favor.cli import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/xian_favor/api.py b/xian_favor/api.py new file mode 100644 index 0000000..2e0a3ac --- /dev/null +++ b/xian_favor/api.py @@ -0,0 +1,567 @@ +"""API服务""" + +from flask import Flask, request, jsonify, render_template_string +from flask_cors import CORS +import os + +from .db import db +from .config import API_HOST, API_PORT, ITEM_TYPES, TODO_STATUS, PRIORITY_LEVELS + +app = Flask(__name__, + template_folder=os.path.join(os.path.dirname(__file__), '../web/templates'), + static_folder=os.path.join(os.path.dirname(__file__), '../web/static')) +CORS(app) + +# ============ API 路由 ============ + +@app.route('/api/items', methods=['GET']) +def list_items(): + """列出条目""" + items = db.list_items( + type=request.args.get('type'), + status=request.args.get('status'), + tag=request.args.get('tag'), + keyword=request.args.get('keyword'), + limit=int(request.args.get('limit', 50)), + offset=int(request.args.get('offset', 0)) + ) + return jsonify({'success': True, 'data': items}) + + +@app.route('/api/items', methods=['POST']) +def create_item(): + """创建条目""" + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '无数据'}), 400 + + item_type = data.get('type', 'text') + if item_type not in ITEM_TYPES: + return jsonify({'success': False, 'error': f'无效类型: {item_type}'}), 400 + + try: + item_id = db.create_item( + type=item_type, + title=data.get('title'), + content=data.get('content'), + url=data.get('url'), + source=data.get('source'), + status=data.get('status', 'pending'), + priority=data.get('priority', 'medium'), + due_date=data.get('due_date'), + note=data.get('note'), + tags=data.get('tags', []) + ) + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}), 201 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/items/', methods=['GET']) +def get_item(item_id): + """获取条目""" + item = db.get_item(item_id) + if not item: + return jsonify({'success': False, 'error': '条目不存在'}), 404 + return jsonify({'success': True, 'data': item}) + + +@app.route('/api/items/', methods=['PUT']) +def update_item(item_id): + """更新条目""" + data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '无数据'}), 400 + + try: + if db.update_item(item_id, **data): + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}) + else: + return jsonify({'success': False, 'error': '条目不存在或无变化'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/items/', methods=['DELETE']) +def delete_item(item_id): + """删除条目""" + if db.delete_item(item_id): + return jsonify({'success': True}) + return jsonify({'success': False, 'error': '条目不存在'}), 404 + + +@app.route('/api/items//done', methods=['POST']) +def complete_item(item_id): + """完成待办""" + item = db.get_item(item_id) + if not item: + return jsonify({'success': False, 'error': '条目不存在'}), 404 + + if item['type'] != 'todo': + return jsonify({'success': False, 'error': '不是待办事项'}), 400 + + db.update_item(item_id, status='completed') + item = db.get_item(item_id) + return jsonify({'success': True, 'data': item}) + + +@app.route('/api/tags', methods=['GET']) +def list_tags(): + """列出标签""" + tags = db.list_tags() + return jsonify({'success': True, 'data': tags}) + + +@app.route('/api/tags', methods=['POST']) +def create_tag(): + """创建标签""" + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'success': False, 'error': '标签名不能为空'}), 400 + + try: + tag_id = db.create_tag(name, data.get('color', '#3498db')) + return jsonify({'success': True, 'data': {'id': tag_id, 'name': name}}), 201 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/tags/', methods=['DELETE']) +def delete_tag(tag_id): + """删除标签""" + if db.delete_tag(tag_id=tag_id): + return jsonify({'success': True}) + return jsonify({'success': False, 'error': '标签不存在'}), 404 + + +@app.route('/api/stats', methods=['GET']) +def get_stats(): + """获取统计""" + stats = db.stats() + return jsonify({'success': True, 'data': stats}) + + +@app.route('/api/search', methods=['GET']) +def search_items(): + """搜索条目""" + keyword = request.args.get('q', '') + if not keyword: + return jsonify({'success': False, 'error': '请提供搜索关键词'}), 400 + + items = db.list_items( + keyword=keyword, + type=request.args.get('type'), + limit=int(request.args.get('limit', 50)) + ) + return jsonify({'success': True, 'data': items}) + + +# ============ Web 页面 ============ + +@app.route('/') +def index(): + """主页""" + return render_template_string(INDEX_TEMPLATE) + + +# ============ Web 模板 ============ + +INDEX_TEMPLATE = ''' + + + + + + Xian Favor - 收藏系统 + + + + + +
+
+ + + + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+
总条目
+

0

+
+
+
+
+
+
+
待处理
+

0

+
+
+
+
+
+
+
进行中
+

0

+
+
+
+
+
+
+
已完成
+

0

+
+
+
+
+ + +
+
+
+
+ + + + + + + + +''' + + +def start_server(host: str = API_HOST, port: int = API_PORT): + """启动服务""" + app.run(host=host, port=port, debug=False) \ No newline at end of file diff --git a/xian_favor/cli.py b/xian_favor/cli.py new file mode 100644 index 0000000..6eda465 --- /dev/null +++ b/xian_favor/cli.py @@ -0,0 +1,430 @@ +"""命令行工具""" + +import argparse +import sys +import json +from datetime import datetime +from typing import List + +from .db import db +from .config import ITEM_TYPES, TODO_STATUS, PRIORITY_LEVELS + + +def main(): + parser = argparse.ArgumentParser( + description="Xian Favor - 收藏关注系统", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + xian_favor add text "这是我的笔记" -t "笔记,重要" + xian_favor add link "https://example.com" --title "示例网站" -t "技术" + xian_favor add todo "完成项目" -p high -d "2024-12-31" -t "工作" + xian_favor add column "https://column.example.com/feed" --title "技术专栏" -t "RSS" + xian_favor list + xian_favor list --type todo --status pending + xian_favor search "关键词" + xian_favor show 1 + xian_favor edit 1 --status completed --note "已完成" + xian_favor done 1 + xian_favor delete 1 + xian_favor tags + xian_favor stats + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="命令") + + # ============ add 命令 ============ + add_parser = subparsers.add_parser("add", help="添加新条目") + add_parser.add_argument("type", choices=ITEM_TYPES, help="类型: text/link/column/todo") + add_parser.add_argument("content", help="内容或URL") + add_parser.add_argument("--title", "-T", help="标题") + add_parser.add_argument("--url", "-u", help="URL (link/column类型)") + add_parser.add_argument("--source", "-s", help="来源") + add_parser.add_argument("--status", choices=TODO_STATUS, default="pending", help="状态 (todo)") + add_parser.add_argument("--priority", "-p", choices=PRIORITY_LEVELS, default="medium", help="优先级") + add_parser.add_argument("--due-date", "-d", help="截止日期 (YYYY-MM-DD)") + add_parser.add_argument("--note", "-n", help="备注") + add_parser.add_argument("--tags", "-t", help="标签 (逗号分隔)") + + # ============ list 命令 ============ + list_parser = subparsers.add_parser("list", help="列出条目") + list_parser.add_argument("--type", choices=ITEM_TYPES, help="类型过滤") + list_parser.add_argument("--status", choices=TODO_STATUS, help="状态过滤") + list_parser.add_argument("--tag", help="标签过滤") + list_parser.add_argument("--limit", "-l", type=int, default=20, help="数量限制") + list_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ show 命令 ============ + show_parser = subparsers.add_parser("show", help="查看详情") + show_parser.add_argument("id", type=int, help="条目ID") + show_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ edit 命令 ============ + edit_parser = subparsers.add_parser("edit", help="编辑条目") + edit_parser.add_argument("id", type=int, help="条目ID") + edit_parser.add_argument("--title", "-T", help="标题") + edit_parser.add_argument("--content", "-c", help="内容") + edit_parser.add_argument("--url", "-u", help="URL") + edit_parser.add_argument("--source", "-s", help="来源") + edit_parser.add_argument("--status", choices=TODO_STATUS, help="状态") + edit_parser.add_argument("--priority", "-p", choices=PRIORITY_LEVELS, help="优先级") + edit_parser.add_argument("--due-date", "-d", help="截止日期") + edit_parser.add_argument("--note", "-n", help="备注") + edit_parser.add_argument("--tags", "-t", help="标签 (逗号分隔, 覆盖)") + + # ============ done 命令 ============ + done_parser = subparsers.add_parser("done", help="完成待办") + done_parser.add_argument("id", type=int, help="条目ID") + + # ============ delete 命令 ============ + delete_parser = subparsers.add_parser("delete", help="删除条目") + delete_parser.add_argument("id", type=int, help="条目ID") + delete_parser.add_argument("-f", "--force", action="store_true", help="强制删除不确认") + + # ============ search 命令 ============ + search_parser = subparsers.add_parser("search", help="搜索条目") + search_parser.add_argument("keyword", help="关键词") + search_parser.add_argument("--type", choices=ITEM_TYPES, help="类型过滤") + search_parser.add_argument("--limit", "-l", type=int, default=20, help="数量限制") + search_parser.add_argument("--json", "-j", action="store_true", help="JSON输出") + + # ============ tags 命令 ============ + tags_parser = subparsers.add_parser("tags", help="标签管理") + tags_parser.add_argument("--delete", "-d", help="删除标签") + + # ============ stats 命令 ============ + subparsers.add_parser("stats", help="统计信息") + + # ============ serve 命令 ============ + serve_parser = subparsers.add_parser("serve", help="启动API服务") + serve_parser.add_argument("--host", default="0.0.0.0", help="主机") + serve_parser.add_argument("--port", type=int, default=19014, help="端口") + + # 解析参数 + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + # 执行命令 + try: + if args.command == "add": + cmd_add(args) + elif args.command == "list": + cmd_list(args) + elif args.command == "show": + cmd_show(args) + elif args.command == "edit": + cmd_edit(args) + elif args.command == "done": + cmd_done(args) + elif args.command == "delete": + cmd_delete(args) + elif args.command == "search": + cmd_search(args) + elif args.command == "tags": + cmd_tags(args) + elif args.command == "stats": + cmd_stats(args) + elif args.command == "serve": + cmd_serve(args) + except Exception as e: + print(f"❌ 错误: {e}", file=sys.stderr) + sys.exit(1) + + +# ============ 命令实现 ============ + +def parse_tags(tags_str: str) -> List[str]: + """解析标签字符串""" + if not tags_str: + return [] + return [t.strip() for t in tags_str.split(",") if t.strip()] + + +def format_item(item: dict, brief: bool = True) -> str: + """格式化条目显示""" + type_icons = { + "text": "📝", + "link": "🔗", + "column": "📰", + "todo": "✅" + } + status_icons = { + "pending": "⏳", + "in_progress": "🔄", + "completed": "✅" + } + priority_icons = { + "low": "🟢", + "medium": "🟡", + "high": "🟠", + "urgent": "🔴" + } + + icon = type_icons.get(item['type'], "📄") + status = status_icons.get(item.get('status', ''), '') + priority = priority_icons.get(item.get('priority', ''), '') + + title = item.get('title') or item.get('content', '')[:50] + tags_str = f" [{', '.join(item['tags'])}]" if item.get('tags') else "" + + if brief: + return f" {icon} [{item['id']}] {title}{tags_str}" + else: + lines = [ + f"{icon} [{item['id']}] {title}", + f" 类型: {item['type']}", + ] + if item.get('content'): + lines.append(f" 内容: {item['content'][:200]}") + if item.get('url'): + lines.append(f" URL: {item['url']}") + if item.get('source'): + lines.append(f" 来源: {item['source']}") + if item['type'] == 'todo': + lines.append(f" 状态: {status} {item.get('status', 'pending')}") + lines.append(f" 优先级: {priority} {item.get('priority', 'medium')}") + if item.get('due_date'): + lines.append(f" 截止: {item['due_date']}") + if item.get('note'): + lines.append(f" 备注: {item['note']}") + if item.get('tags'): + lines.append(f" 标签: {', '.join(item['tags'])}") + lines.append(f" 创建: {item['created_at'][:19]}") + return "\n".join(lines) + + +def cmd_add(args): + """添加条目""" + tags = parse_tags(args.tags) + + # 根据类型处理内容 + content = args.content + url = args.url + + if args.type == "link": + url = url or args.content + content = None + elif args.type == "column": + url = url or args.content + content = None + + item_id = db.create_item( + type=args.type, + title=args.title, + content=content, + url=url, + source=args.source, + status=args.status, + priority=args.priority, + due_date=args.due_date, + note=args.note, + tags=tags + ) + + item = db.get_item(item_id) + print(f"✅ 创建成功 (ID: {item_id})") + print(format_item(item, brief=False)) + + +def cmd_list(args): + """列出条目""" + items = db.list_items( + type=args.type, + status=args.status, + tag=args.tag, + limit=args.limit + ) + + if args.json: + print(json.dumps(items, ensure_ascii=False, indent=2)) + return + + if not items: + print("📭 没有找到条目") + return + + type_labels = {"text": "文本", "link": "链接", "column": "专栏", "todo": "待办"} + filter_desc = [] + if args.type: + filter_desc.append(f"类型: {type_labels.get(args.type, args.type)}") + if args.status: + filter_desc.append(f"状态: {args.status}") + if args.tag: + filter_desc.append(f"标签: {args.tag}") + + if filter_desc: + print(f"📋 筛选: {' | '.join(filter_desc)}") + else: + print(f"📋 全部条目 ({len(items)} 条)") + print("-" * 50) + + for item in items: + print(format_item(item)) + + +def cmd_show(args): + """查看详情""" + item = db.get_item(args.id) + + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + if args.json: + print(json.dumps(item, ensure_ascii=False, indent=2)) + return + + print(format_item(item, brief=False)) + + +def cmd_edit(args): + """编辑条目""" + update_data = {} + + if args.title is not None: + update_data['title'] = args.title + if args.content is not None: + update_data['content'] = args.content + if args.url is not None: + update_data['url'] = args.url + if args.source is not None: + update_data['source'] = args.source + if args.status is not None: + update_data['status'] = args.status + if args.priority is not None: + update_data['priority'] = args.priority + if args.due_date is not None: + update_data['due_date'] = args.due_date + if args.note is not None: + update_data['note'] = args.note + if args.tags is not None: + update_data['tags'] = parse_tags(args.tags) + + if not update_data: + print("❌ 没有指定要更新的字段") + return + + if db.update_item(args.id, **update_data): + item = db.get_item(args.id) + print(f"✅ 更新成功 (ID: {args.id})") + print(format_item(item, brief=False)) + else: + print(f"❌ 更新失败: 条目不存在或没有变化") + + +def cmd_done(args): + """完成待办""" + item = db.get_item(args.id) + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + if item['type'] != 'todo': + print(f"❌ 不是待办事项: {args.id}") + return + + if db.update_item(args.id, status='completed'): + print(f"✅ 已完成待办 (ID: {args.id})") + else: + print(f"❌ 操作失败") + + +def cmd_delete(args): + """删除条目""" + if not args.force: + item = db.get_item(args.id) + if not item: + print(f"❌ 条目不存在: {args.id}") + return + + print(format_item(item, brief=False)) + confirm = input("确认删除? [y/N] ") + if confirm.lower() != 'y': + print("❌ 取消删除") + return + + if db.delete_item(args.id): + print(f"✅ 已删除 (ID: {args.id})") + else: + print(f"❌ 删除失败") + + +def cmd_search(args): + """搜索条目""" + items = db.list_items( + type=args.type, + keyword=args.keyword, + limit=args.limit + ) + + if args.json: + print(json.dumps(items, ensure_ascii=False, indent=2)) + return + + if not items: + print(f"🔍 没有找到匹配 '{args.keyword}' 的条目") + return + + print(f"🔍 搜索 '{args.keyword}' ({len(items)} 条)") + print("-" * 50) + + for item in items: + print(format_item(item)) + + +def cmd_tags(args): + """标签管理""" + if args.delete: + if db.delete_tag(name=args.delete): + print(f"✅ 已删除标签: {args.delete}") + else: + print(f"❌ 标签不存在: {args.delete}") + return + + tags = db.list_tags() + if not tags: + print("🏷️ 没有标签") + return + + print(f"🏷️ 标签列表 ({len(tags)} 个)") + print("-" * 50) + for tag in tags: + print(f" • {tag['name']} ({tag['item_count']} 条)") + + +def cmd_stats(args): + """统计信息""" + stats = db.stats() + + print("📊 收藏统计") + print("-" * 30) + print(f"总条目: {stats['total']}") + + if stats.get('by_type'): + print("\n按类型:") + type_labels = {"text": "文本", "link": "链接", "column": "专栏", "todo": "待办"} + for t, count in stats['by_type'].items(): + print(f" • {type_labels.get(t, t)}: {count}") + + if stats.get('todo_status'): + print("\n待办状态:") + status_labels = {"pending": "待处理", "in_progress": "进行中", "completed": "已完成"} + for s, count in stats['todo_status'].items(): + print(f" • {status_labels.get(s, s)}: {count}") + + print(f"\n标签数: {stats['tags']}") + + +def cmd_serve(args): + """启动API服务""" + from .api import start_server + print(f"🚀 启动API服务: http://{args.host}:{args.port}") + start_server(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/xian_favor/config.py b/xian_favor/config.py new file mode 100644 index 0000000..07d2e6e --- /dev/null +++ b/xian_favor/config.py @@ -0,0 +1,26 @@ +"""配置文件""" + +import os +from pathlib import Path + +# 数据目录 - 使用用户可访问的路径 +# 默认在 ~/.xian_favor/ 目录下 +DEFAULT_DATA_DIR = Path.home() / ".xian_favor" +DATA_DIR = Path(os.getenv("XIAN_FAVOR_DATA_DIR", str(DEFAULT_DATA_DIR))) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +# 数据库 +DATABASE_URL = os.getenv("XIAN_FAVOR_DB", str(DATA_DIR / "xian_favor.db")) + +# API服务 +API_HOST = os.getenv("XIAN_FAVOR_HOST", "0.0.0.0") +API_PORT = int(os.getenv("XIAN_FAVOR_PORT", "19014")) + +# 内容类型 +ITEM_TYPES = ["text", "link", "column", "todo"] + +# 待办状态 +TODO_STATUS = ["pending", "in_progress", "completed"] + +# 优先级 +PRIORITY_LEVELS = ["low", "medium", "high", "urgent"] \ No newline at end of file diff --git a/xian_favor/db.py b/xian_favor/db.py new file mode 100644 index 0000000..990e3ea --- /dev/null +++ b/xian_favor/db.py @@ -0,0 +1,321 @@ +"""数据库操作""" + +import sqlite3 +import json +from datetime import datetime +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +from .config import DATABASE_URL, TODO_STATUS, PRIORITY_LEVELS + + +class Database: + """SQLite数据库管理""" + + def __init__(self, db_path: str = DATABASE_URL): + self.db_path = db_path + self._initialized = False + + def _ensure_init(self): + """确保数据库已初始化""" + if self._initialized: + return + self._init_db() + self._initialized = True + + @contextmanager + def get_conn(self): + """获取数据库连接""" + conn = sqlite3.connect(self.db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + # 启用WAL模式,提高并发性能 + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + try: + yield conn + finally: + conn.close() + + def _init_db(self): + """初始化数据库表""" + with self.get_conn() as conn: + cursor = conn.cursor() + + # 主内容表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS items ( + 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, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + # 标签表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT DEFAULT '#3498db', + created_at TEXT NOT NULL + ) + """) + + # 内容-标签关联表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS item_tags ( + item_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (item_id, tag_id), + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ) + """) + + # 创建索引 + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_type ON items(type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_status ON items(status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_created ON items(created_at)") + 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)") + + conn.commit() + + # ============ Item 操作 ============ + + 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: + """创建新条目""" + self._ensure_init() + now = datetime.now().isoformat() + + # 验证状态 + if type == "todo" and status not in TODO_STATUS: + status = "pending" + if priority not in PRIORITY_LEVELS: + priority = "medium" + + 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)) + item_id = cursor.lastrowid + + # 添加标签 + if tags: + self._add_tags_to_item(conn, item_id, tags) + + conn.commit() + return item_id + + def get_item(self, item_id: int) -> Optional[Dict[str, Any]]: + """获取单个条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM items WHERE id = ?", (item_id,)) + row = cursor.fetchone() + if not row: + return None + + item = dict(row) + item['tags'] = self._get_item_tags(conn, item_id) + 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]]: + """列出条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + + query = "SELECT DISTINCT i.* 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" + 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 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) + + 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 + + def update_item(self, item_id: int, **kwargs) -> bool: + """更新条目""" + allowed_fields = ['type', 'title', 'content', 'url', 'source', 'status', 'priority', 'due_date', 'note'] + update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields} + + if not update_fields and 'tags' not in kwargs: + return False + + now = datetime.now().isoformat() + + with self.get_conn() as conn: + cursor = conn.cursor() + + if update_fields: + set_clause = ", ".join(f"{k} = ?" for k in update_fields.keys()) + set_clause += ", updated_at = ?" + values = list(update_fields.values()) + [now, item_id] + cursor.execute(f"UPDATE items SET {set_clause} WHERE id = ?", values) + + if 'tags' in kwargs: + # 先删除旧标签关联 + cursor.execute("DELETE FROM item_tags WHERE item_id = ?", (item_id,)) + # 添加新标签 + if kwargs['tags']: + self._add_tags_to_item(conn, item_id, kwargs['tags']) + + conn.commit() + return cursor.rowcount > 0 + + def delete_item(self, item_id: int) -> bool: + """删除条目""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM items WHERE id = ?", (item_id,)) + conn.commit() + return cursor.rowcount > 0 + + # ============ Tag 操作 ============ + + def create_tag(self, name: str, color: str = "#3498db") -> int: + """创建标签""" + now = datetime.now().isoformat() + with self.get_conn() as conn: + cursor = conn.cursor() + try: + cursor.execute("INSERT INTO tags (name, color, created_at) VALUES (?, ?, ?)", + (name, color, now)) + conn.commit() + return cursor.lastrowid + except sqlite3.IntegrityError: + # 标签已存在 + cursor.execute("SELECT id FROM tags WHERE name = ?", (name,)) + return cursor.fetchone()['id'] + + def list_tags(self) -> List[Dict[str, Any]]: + """列出所有标签""" + with self.get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT t.*, COUNT(it.item_id) as item_count + FROM tags t + LEFT JOIN item_tags it ON t.id = it.tag_id + GROUP BY t.id + ORDER BY t.name + """) + return [dict(row) for row in cursor.fetchall()] + + def delete_tag(self, tag_id: int = None, name: str = None) -> bool: + """删除标签""" + with self.get_conn() as conn: + cursor = conn.cursor() + if name: + cursor.execute("DELETE FROM tags WHERE name = ?", (name,)) + elif tag_id: + cursor.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) + conn.commit() + return cursor.rowcount > 0 + + # ============ 辅助方法 ============ + + def _add_tags_to_item(self, conn, item_id: int, tags: List[str]): + """为条目添加标签""" + cursor = conn.cursor() + for tag_name in tags: + tag_name = tag_name.strip() + if not tag_name: + continue + # 确保标签存在 - 使用同一个连接 + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + row = cursor.fetchone() + if row: + tag_id = row['id'] + else: + # 创建新标签 + now = datetime.now().isoformat() + cursor.execute("INSERT INTO tags (name, color, created_at) VALUES (?, '#3498db', ?)", + (tag_name, now)) + tag_id = cursor.lastrowid + # 创建关联 + cursor.execute("INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)", + (item_id, tag_id)) + + def _get_item_tags(self, conn, item_id: int) -> List[str]: + """获取条目的标签""" + cursor = conn.cursor() + cursor.execute(""" + SELECT t.name FROM tags t + JOIN item_tags it ON t.id = it.tag_id + WHERE it.item_id = ? + ORDER BY t.name + """, (item_id,)) + return [row['name'] for row in cursor.fetchall()] + + def stats(self) -> Dict[str, Any]: + """获取统计信息""" + self._ensure_init() + with self.get_conn() as conn: + cursor = conn.cursor() + + stats = {} + + # 总数 + cursor.execute("SELECT COUNT(*) as count FROM items") + stats['total'] = cursor.fetchone()['count'] + + # 按类型统计 + cursor.execute("SELECT type, COUNT(*) as count FROM items GROUP BY type") + stats['by_type'] = {row['type']: row['count'] for row in cursor.fetchall()} + + # 待办状态统计 + cursor.execute("SELECT status, COUNT(*) as count FROM items WHERE type = 'todo' GROUP BY status") + stats['todo_status'] = {row['status']: row['count'] for row in cursor.fetchall()} + + # 标签数 + cursor.execute("SELECT COUNT(*) as count FROM tags") + stats['tags'] = cursor.fetchone()['count'] + + return stats + + +# 全局数据库实例 +db = Database() \ No newline at end of file