feat: 文件夹功能
- 数据库添加 folders 表和 items.folder_id 字段 - API 新增文件夹 CRUD 接口和条目移动接口 - 侧边栏每个类别下显示文件夹列表 - 新建文件夹按钮和模态框 - 条目卡片添加「移动到文件夹」按钮 - 点击文件夹过滤显示该文件夹下的数据
This commit is contained in:
@@ -24,12 +24,21 @@ def list_items():
|
||||
elif starred_param == 'false' or starred_param == '0':
|
||||
starred = False
|
||||
|
||||
# 文件夹ID参数
|
||||
folder_id_param = request.args.get('folder_id')
|
||||
folder_id = None
|
||||
if folder_id_param == '-1':
|
||||
folder_id = -1 # 未分类
|
||||
elif folder_id_param:
|
||||
folder_id = int(folder_id_param)
|
||||
|
||||
items = db.list_items(
|
||||
type=request.args.get('type'),
|
||||
status=request.args.get('status'),
|
||||
tag=request.args.get('tag'),
|
||||
keyword=request.args.get('keyword'),
|
||||
starred=starred,
|
||||
folder_id=folder_id,
|
||||
sort_by=request.args.get('sort_by'),
|
||||
sort_order=request.args.get('sort_order'),
|
||||
limit=int(request.args.get('limit', 50)),
|
||||
@@ -76,7 +85,8 @@ def create_item():
|
||||
due_date=data.get('due_date'),
|
||||
note=data.get('note'),
|
||||
tags=data.get('tags', []),
|
||||
is_starred=data.get('is_starred', False)
|
||||
is_starred=data.get('is_starred', False),
|
||||
folder_id=data.get('folder_id')
|
||||
)
|
||||
item = db.get_item(item_id)
|
||||
return jsonify({'success': True, 'data': item}), 201
|
||||
@@ -742,6 +752,84 @@ def delete_backup(backup_name):
|
||||
return jsonify({'success': False, 'error': '备份不存在'}), 404
|
||||
|
||||
|
||||
# ============ Folder 文件夹管理 API ============
|
||||
|
||||
@app.route('/api/folders', methods=['GET'])
|
||||
def list_folders():
|
||||
"""列出文件夹"""
|
||||
type_filter = request.args.get('type')
|
||||
folders = db.list_folders(type=type_filter)
|
||||
return jsonify({'success': True, 'data': folders})
|
||||
|
||||
|
||||
@app.route('/api/folders', methods=['POST'])
|
||||
def create_folder():
|
||||
"""创建文件夹"""
|
||||
data = request.get_json()
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
type_val = data.get('type', 'text')
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': '文件夹名称不能为空'}), 400
|
||||
|
||||
if type_val not in ITEM_TYPES:
|
||||
return jsonify({'success': False, 'error': f'无效类型: {type_val}'}), 400
|
||||
|
||||
try:
|
||||
folder_id = db.create_folder(name=name, type=type_val)
|
||||
folder = db.get_folder(folder_id)
|
||||
return jsonify({'success': True, 'data': folder}), 201
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/folders/<int:folder_id>', methods=['GET'])
|
||||
def get_folder(folder_id):
|
||||
"""获取文件夹"""
|
||||
folder = db.get_folder(folder_id)
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
|
||||
return jsonify({'success': True, 'data': folder})
|
||||
|
||||
|
||||
@app.route('/api/folders/<int:folder_id>', methods=['PUT'])
|
||||
def update_folder(folder_id):
|
||||
"""更新文件夹"""
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'error': '文件夹名称不能为空'}), 400
|
||||
|
||||
if db.update_folder(folder_id, name=name):
|
||||
folder = db.get_folder(folder_id)
|
||||
return jsonify({'success': True, 'data': folder})
|
||||
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
|
||||
|
||||
|
||||
@app.route('/api/folders/<int:folder_id>', methods=['DELETE'])
|
||||
def delete_folder(folder_id):
|
||||
"""删除文件夹"""
|
||||
move_to_root = request.args.get('move_items', 'true') == 'true'
|
||||
|
||||
if db.delete_folder(folder_id, move_items_to_root=move_to_root):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'error': '文件夹不存在'}), 404
|
||||
|
||||
|
||||
@app.route('/api/items/<int:item_id>/move', methods=['POST'])
|
||||
def move_item_to_folder(item_id):
|
||||
"""将条目移动到文件夹"""
|
||||
data = request.get_json()
|
||||
folder_id = data.get('folder_id') # None 表示移出文件夹
|
||||
|
||||
if db.move_item_to_folder(item_id, folder_id):
|
||||
item = db.get_item(item_id)
|
||||
return jsonify({'success': True, 'data': item})
|
||||
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
||||
|
||||
|
||||
# ============ Web 页面 ============
|
||||
|
||||
@app.route('/')
|
||||
@@ -841,6 +929,30 @@ INDEX_TEMPLATE = '''
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 文件夹列表样式 */
|
||||
.sidebar-section .section-header { font-weight: 500; }
|
||||
.folder-list {
|
||||
padding-left: 10px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.folder-list a {
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
color: #adb5bd;
|
||||
}
|
||||
.folder-list a:hover, .folder-list a.active {
|
||||
background: #495057;
|
||||
color: #fff;
|
||||
}
|
||||
.folder-list a i { margin-right: 5px; }
|
||||
.folder-action {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
padding: 4px 20px;
|
||||
}
|
||||
.folder-action:hover { color: #adb5bd; }
|
||||
|
||||
/* 离线遮罩 */
|
||||
.offline-overlay {
|
||||
position: fixed;
|
||||
@@ -927,10 +1039,35 @@ INDEX_TEMPLATE = '''
|
||||
<nav>
|
||||
<a href="#" class="active" data-filter="all"><i class="bi bi-inbox"></i> 全部</a>
|
||||
<a href="#" data-filter="starred"><i class="bi bi-star-fill" style="color:#ffc107;"></i> 重点关注</a>
|
||||
<a href="#" data-filter="text"><i class="bi bi-file-text"></i> 文本</a>
|
||||
<a href="#" data-filter="link"><i class="bi bi-link-45deg"></i> 链接</a>
|
||||
<a href="#" data-filter="column"><i class="bi bi-newspaper"></i> 专栏</a>
|
||||
<a href="#" data-filter="todo"><i class="bi bi-check2-square"></i> 待办</a>
|
||||
|
||||
<!-- 文本类别 -->
|
||||
<div class="sidebar-section">
|
||||
<a href="#" data-filter="text" class="section-header"><i class="bi bi-file-text"></i> 文本</a>
|
||||
<div class="folder-list" id="folderList-text"></div>
|
||||
<a href="#" class="folder-action" onclick="showNewFolderModal('text'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
|
||||
</div>
|
||||
|
||||
<!-- 链接类别 -->
|
||||
<div class="sidebar-section">
|
||||
<a href="#" data-filter="link" class="section-header"><i class="bi bi-link-45deg"></i> 链接</a>
|
||||
<div class="folder-list" id="folderList-link"></div>
|
||||
<a href="#" class="folder-action" onclick="showNewFolderModal('link'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
|
||||
</div>
|
||||
|
||||
<!-- 专栏类别 -->
|
||||
<div class="sidebar-section">
|
||||
<a href="#" data-filter="column" class="section-header"><i class="bi bi-newspaper"></i> 专栏</a>
|
||||
<div class="folder-list" id="folderList-column"></div>
|
||||
<a href="#" class="folder-action" onclick="showNewFolderModal('column'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
|
||||
</div>
|
||||
|
||||
<!-- 待办类别 -->
|
||||
<div class="sidebar-section">
|
||||
<a href="#" data-filter="todo" class="section-header"><i class="bi bi-check2-square"></i> 待办</a>
|
||||
<div class="folder-list" id="folderList-todo"></div>
|
||||
<a href="#" class="folder-action" onclick="showNewFolderModal('todo'); return false;"><i class="bi bi-folder-plus"></i> 新建文件夹</a>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary">
|
||||
<a href="#" data-filter="pending"><i class="bi bi-clock"></i> 待处理</a>
|
||||
<a href="#" data-filter="in_progress"><i class="bi bi-arrow-repeat"></i> 进行中</a>
|
||||
@@ -1509,15 +1646,73 @@ INDEX_TEMPLATE = '''
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建文件夹模态框 -->
|
||||
<div class="modal fade" id="newFolderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-folder-plus"></i> 新建文件夹</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="newFolderType">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">文件夹名称</label>
|
||||
<input type="text" id="newFolderName" class="form-control" placeholder="输入文件夹名称">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">所属类别</label>
|
||||
<input type="text" id="newFolderTypeName" class="form-control" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createFolder()">
|
||||
<i class="bi bi-check"></i> 创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动到文件夹模态框 -->
|
||||
<div class="modal fade" id="moveToFolderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-folder2-open"></i> 移动到文件夹</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="moveItemId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">选择目标文件夹</label>
|
||||
<select id="moveFolderSelect" class="form-select">
|
||||
<option value="">-- 移出文件夹(未分类)--</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="moveFolderList" class="border rounded p-2 bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
<!-- 动态填充文件夹列表 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="moveItemToFolder()">
|
||||
<i class="bi bi-check"></i> 移动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const API_BASE = '/api';
|
||||
let currentFilter = { type: '', status: '', starred: null };
|
||||
let currentFilter = { type: '', status: '', starred: null, folder_id: null };
|
||||
let currentSort = { sort_by: '', sort_order: '' };
|
||||
let currentPage = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
// ============ 连接状态检测 ============
|
||||
let allFolders = {}; // 按类型存储文件夹
|
||||
let isOnline = true;
|
||||
let connectionCheckTimer = null;
|
||||
const CONNECTION_CHECK_INTERVAL = 5000; // 5秒检测一次
|
||||
@@ -1649,11 +1844,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 确保初始状态清空
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('typeFilter').value = '';
|
||||
currentFilter = { type: '', status: '', starred: null };
|
||||
currentFilter = { type: '', status: '', starred: null, folder_id: null };
|
||||
|
||||
await loadStats(); // 先加载统计,确保总数可用
|
||||
loadItems();
|
||||
loadTags();
|
||||
loadFolders(); // 加载文件夹
|
||||
loadReminders(); // 加载提醒
|
||||
|
||||
// 定时刷新提醒(每5分钟)
|
||||
@@ -1699,13 +1895,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
const filter = a.dataset.filter;
|
||||
if (filter === 'starred') {
|
||||
currentFilter = { type: '', status: '', starred: true };
|
||||
currentFilter = { type: '', status: '', starred: true, folder_id: null };
|
||||
} else if (['text', 'link', 'column', 'todo'].includes(filter)) {
|
||||
currentFilter = { type: filter, status: '', starred: null };
|
||||
currentFilter = { type: filter, status: '', starred: null, folder_id: null };
|
||||
} else if (['pending', 'in_progress', 'completed'].includes(filter)) {
|
||||
currentFilter = { type: 'todo', status: filter, starred: null };
|
||||
currentFilter = { type: 'todo', status: filter, starred: null, folder_id: null };
|
||||
} else {
|
||||
currentFilter = { type: '', status: '', starred: null };
|
||||
currentFilter = { type: '', status: '', starred: null, folder_id: null };
|
||||
}
|
||||
loadItems();
|
||||
});
|
||||
@@ -1720,6 +1916,7 @@ async function loadItems(page = 1) {
|
||||
if (currentFilter.type) url += `&type=${currentFilter.type}`;
|
||||
if (currentFilter.status) url += `&status=${currentFilter.status}`;
|
||||
if (currentFilter.starred !== null) url += `&starred=${currentFilter.starred ? 'true' : 'false'}`;
|
||||
if (currentFilter.folder_id !== null) url += `&folder_id=${currentFilter.folder_id}`;
|
||||
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
|
||||
if (currentSort.sort_by) url += `&sort_by=${currentSort.sort_by}`;
|
||||
if (currentSort.sort_order) url += `&sort_order=${currentSort.sort_order}`;
|
||||
@@ -1767,6 +1964,7 @@ function renderItems(items) {
|
||||
<i class="bi bi-star${item.is_starred ? '-fill' : ''}" style="font-size:11px; ${item.is_starred ? 'color:#ffc107;' : ''}"></i>
|
||||
</button>
|
||||
${item.type !== 'todo' ? `<button class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="showConvertModal(${item.id})" title="转为待办"><i class="bi bi-arrow-repeat" style="font-size:11px;"></i></button>` : ''}
|
||||
<button class="btn btn-sm btn-outline-dark py-0 px-1" onclick="showMoveToFolderModal(${item.id}, '${item.type}')" title="移动到文件夹"><i class="bi bi-folder2-open" style="font-size:11px;"></i></button>
|
||||
<button class="btn btn-sm btn-outline-info py-0 px-1" onclick="showSendEmailModal(${item.id})" title="发送邮件"><i class="bi bi-envelope" style="font-size:11px;"></i></button>
|
||||
<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="openEditModal(${item.id})" title="编辑"><i class="bi bi-pencil" style="font-size:11px;"></i></button>
|
||||
${item.type === 'todo' && item.status !== 'completed' ? `<button class="btn btn-sm btn-outline-success py-0 px-1" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg" style="font-size:11px;"></i></button>` : ''}
|
||||
@@ -3154,6 +3352,152 @@ async function emptyTrash() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Folder 文件夹管理 ============
|
||||
|
||||
async function loadFolders() {
|
||||
const res = await fetch(`${API_BASE}/folders`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// 按类型分组
|
||||
allFolders = {};
|
||||
data.data.forEach(f => {
|
||||
if (!allFolders[f.type]) allFolders[f.type] = [];
|
||||
allFolders[f.type].push(f);
|
||||
});
|
||||
|
||||
// 渲染每个类型的文件夹列表
|
||||
['text', 'link', 'column', 'todo'].forEach(type => {
|
||||
renderFolderList(type);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderFolderList(type) {
|
||||
const container = document.getElementById(`folderList-${type}`);
|
||||
if (!container) return;
|
||||
|
||||
const folders = allFolders[type] || [];
|
||||
|
||||
if (folders.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = folders.map(f => `
|
||||
<a href="#" data-folder="${f.id}" onclick="filterByFolder('${type}', ${f.id}); return false;">
|
||||
<i class="bi bi-folder"></i> ${f.name} <small class="text-muted">(${f.item_count || 0})</small>
|
||||
</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function showNewFolderModal(type) {
|
||||
// 离线检查
|
||||
if (!checkOnlineBeforeAction('新建文件夹')) return;
|
||||
|
||||
document.getElementById('newFolderType').value = type;
|
||||
document.getElementById('newFolderName').value = '';
|
||||
|
||||
const typeLabels = { text: '📝 文本', link: '🔗 链接', column: '📰 专栏', todo: '✅ 待办' };
|
||||
document.getElementById('newFolderTypeName').value = typeLabels[type] || type;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('newFolderModal')).show();
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const type = document.getElementById('newFolderType').value;
|
||||
const name = document.getElementById('newFolderName').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('请输入文件夹名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/folders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, type })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('newFolderModal')).hide();
|
||||
loadFolders();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('创建失败: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
function filterByFolder(type, folderId) {
|
||||
// 更新侧边栏选中状态
|
||||
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
|
||||
document.querySelector(`a[data-folder="${folderId}"]`)?.classList.add('active');
|
||||
|
||||
// 设置过滤条件
|
||||
currentFilter = { type, status: '', starred: null, folder_id: folderId };
|
||||
loadItems(1);
|
||||
}
|
||||
|
||||
// 显示移动到文件夹模态框
|
||||
async function showMoveToFolderModal(itemId, itemType) {
|
||||
// 离线检查
|
||||
if (!checkOnlineBeforeAction('移动数据')) return;
|
||||
|
||||
document.getElementById('moveItemId').value = itemId;
|
||||
|
||||
// 加载该类型的文件夹列表
|
||||
const res = await fetch(`${API_BASE}/folders?type=${itemType}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const select = document.getElementById('moveFolderSelect');
|
||||
const listContainer = document.getElementById('moveFolderList');
|
||||
|
||||
select.innerHTML = '<option value="">-- 移出文件夹(未分类)--</option>' +
|
||||
data.data.map(f => `<option value="${f.id}">${f.name} (${f.item_count || 0}条数据)</option>`).join('');
|
||||
|
||||
// 可点击的列表
|
||||
listContainer.innerHTML = data.data.map(f => `
|
||||
<div class="p-2 border-bottom" style="cursor: pointer;" onclick="selectMoveFolder(${f.id}, '${f.name}')">
|
||||
<i class="bi bi-folder"></i> ${f.name} <span class="text-muted small">(${f.item_count || 0}条)</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
if (data.data.length === 0) {
|
||||
listContainer.innerHTML = '<div class="text-center text-muted py-2">该类型暂无文件夹</div>';
|
||||
}
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('moveToFolderModal')).show();
|
||||
}
|
||||
|
||||
function selectMoveFolder(folderId, folderName) {
|
||||
document.getElementById('moveFolderSelect').value = folderId;
|
||||
}
|
||||
|
||||
async function moveItemToFolder() {
|
||||
const itemId = document.getElementById('moveItemId').value;
|
||||
const folderId = document.getElementById('moveFolderSelect').value || null;
|
||||
|
||||
const res = await fetch(`${API_BASE}/items/${itemId}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_id: folderId ? parseInt(folderId) : null })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('moveToFolderModal')).hide();
|
||||
loadFolders(); // 更新文件夹计数
|
||||
loadItems(currentPage);
|
||||
} else {
|
||||
alert('移动失败: ' + data.error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 备份管理 ============
|
||||
|
||||
async function showBackupManager() {
|
||||
|
||||
158
xian_favor/db.py
158
xian_favor/db.py
@@ -131,6 +131,19 @@ class Database:
|
||||
)
|
||||
""")
|
||||
|
||||
# 文件夹表
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
parent_id INTEGER DEFAULT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES folders(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)")
|
||||
@@ -163,6 +176,17 @@ class Database:
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN deleted_at TEXT")
|
||||
|
||||
# 检查并添加 folder_id 字段(兼容旧数据库)
|
||||
try:
|
||||
cursor.execute("SELECT folder_id FROM items LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.execute("ALTER TABLE items ADD COLUMN folder_id INTEGER DEFAULT NULL")
|
||||
|
||||
# 创建 folder 相关索引
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_items_folder ON items(folder_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_folders_type ON folders(type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# ============ Item 操作 ============
|
||||
@@ -170,7 +194,8 @@ 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, is_starred: bool = False) -> int:
|
||||
tags: List[str] = None, is_starred: bool = False,
|
||||
folder_id: int = None) -> int:
|
||||
"""创建新条目"""
|
||||
self._ensure_init()
|
||||
now = datetime.now().isoformat()
|
||||
@@ -184,9 +209,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, 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))
|
||||
INSERT INTO items (type, title, content, url, source, status, priority, due_date, note, is_starred, folder_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (type, title, content, url, source, status, priority, due_date, note, 1 if is_starred else 0, folder_id, now, now))
|
||||
item_id = cursor.lastrowid
|
||||
|
||||
# 添加标签
|
||||
@@ -210,12 +235,14 @@ class Database:
|
||||
return item
|
||||
|
||||
def list_items(self, type: str = None, status: str = None, tag: str = None,
|
||||
keyword: str = None, starred: bool = None, sort_by: str = None,
|
||||
sort_order: str = None, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
keyword: str = None, starred: bool = None, folder_id: int = 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
|
||||
folder_id: 文件夹ID,None表示不限制,-1表示未分类(folder_id为null)
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
@@ -245,6 +272,15 @@ class Database:
|
||||
conditions.append("i.is_starred = ?")
|
||||
params.append(1 if starred else 0)
|
||||
|
||||
# 文件夹过滤
|
||||
if folder_id is not None:
|
||||
if folder_id == -1:
|
||||
# -1 表示未分类(folder_id 为 null)
|
||||
conditions.append("i.folder_id IS NULL")
|
||||
else:
|
||||
conditions.append("i.folder_id = ?")
|
||||
params.append(folder_id)
|
||||
|
||||
if keyword:
|
||||
conditions.append("(i.title LIKE ? OR i.content LIKE ? OR i.note LIKE ?)")
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
@@ -464,6 +500,116 @@ class Database:
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def move_item_to_folder(self, item_id: int, folder_id: int) -> bool:
|
||||
"""将条目移动到文件夹"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("UPDATE items SET folder_id = ?, updated_at = ? WHERE id = ?", (folder_id, now, item_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# ============ Folder 文件夹操作 ============
|
||||
|
||||
def create_folder(self, name: str, type: str, parent_id: int = None) -> int:
|
||||
"""创建文件夹
|
||||
|
||||
Args:
|
||||
name: 文件夹名称
|
||||
type: 类别类型(text/link/column/todo)
|
||||
parent_id: 父文件夹ID(目前不支持嵌套,预留)
|
||||
"""
|
||||
self._ensure_init()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO folders (name, type, parent_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (name, type, parent_id, now, now))
|
||||
folder_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
return folder_id
|
||||
|
||||
def get_folder(self, folder_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""获取文件夹信息"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM folders WHERE id = ?", (folder_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
folder = dict(row)
|
||||
# 获取文件夹内条目数量
|
||||
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder_id,))
|
||||
folder['item_count'] = cursor.fetchone()[0]
|
||||
return folder
|
||||
|
||||
def list_folders(self, type: str = None) -> List[Dict[str, Any]]:
|
||||
"""列出文件夹
|
||||
|
||||
Args:
|
||||
type: 按类型过滤,None表示列出所有
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
if type:
|
||||
cursor.execute("SELECT * FROM folders WHERE type = ? ORDER BY created_at DESC", (type,))
|
||||
else:
|
||||
cursor.execute("SELECT * FROM folders ORDER BY created_at DESC")
|
||||
|
||||
folders = []
|
||||
for row in cursor.fetchall():
|
||||
folder = dict(row)
|
||||
# 获取文件夹内条目数量
|
||||
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder['id'],))
|
||||
folder['item_count'] = cursor.fetchone()[0]
|
||||
folders.append(folder)
|
||||
|
||||
return folders
|
||||
|
||||
def update_folder(self, folder_id: int, name: str = None) -> bool:
|
||||
"""更新文件夹"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
if name:
|
||||
cursor.execute("UPDATE folders SET name = ?, updated_at = ? WHERE id = ?", (name, now, folder_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
return False
|
||||
|
||||
def delete_folder(self, folder_id: int, move_items_to_root: bool = True) -> bool:
|
||||
"""删除文件夹
|
||||
|
||||
Args:
|
||||
folder_id: 文件夹ID
|
||||
move_items_to_root: 是否将条目移出文件夹(到未分类),True则移动,False则一起删除
|
||||
"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if move_items_to_root:
|
||||
# 将条目移出文件夹(folder_id设为null)
|
||||
cursor.execute("UPDATE items SET folder_id = NULL WHERE folder_id = ?", (folder_id,))
|
||||
else:
|
||||
# 删除文件夹内的所有条目
|
||||
cursor.execute("DELETE FROM items WHERE folder_id = ?", (folder_id,))
|
||||
|
||||
# 删除文件夹
|
||||
cursor.execute("DELETE FROM folders WHERE id = ?", (folder_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def count_items_by_folder(self, folder_id: int) -> int:
|
||||
"""统计文件夹内的条目数量"""
|
||||
with self.get_conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) FROM items WHERE folder_id = ? AND is_deleted = 0", (folder_id,))
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
# ============ Draft 草稿操作 ============
|
||||
|
||||
def save_draft(self, type: str = "text", title: str = None, content: str = None,
|
||||
|
||||
Reference in New Issue
Block a user