Skill: yanguangzhe-collab/bucket-list (version 1.4.0)

--- name: bucket-list version: 1.4.0 description: Use this skill when the user wants to record, view, update, complete, cancel, or review a personal bucket list / wish list. Supports Chinese commands such as "添加愿望：...", "查看愿望清单", "完成了...", and "我们完成了什么", plus a localhost GUI that shares the same JSON data file. --- # 愿望清单 (Bucket List) 记录主人与龙虾一起完成的人生愿望清单。它不是待办软件，也不是人生导师；它是一个本地优先的愿望记录和成就回顾工具。 ## When To Use Use this skill when the user asks to: - add a wish, bucket-list item, life goal, or "愿望" - view pending, completed, or cancelled wishes - mark a wish completed or cancelled - review completed wishes or shared achievements - open or maintain the bucket-list GUI - import, export, or repair bucket-list data ## Quick Start Start the GUI: ```bash cd skills/bucket-list node server.js # open http://localhost:9999/ ``` Use the CLI: ```bash ./bucket-list.sh add "去南极看企鹅" "旅行" ./bucket-list.sh view ./bucket-list.sh complete "南极" "看到了企鹅" ./bucket-list.sh cancel "学吉他" "改学钢琴" ./bucket-list.sh achievements ``` Natural-language entry: ```bash ./bucket-list.sh intent "添加愿望：去南极看企鹅" ./bucket-list.sh intent "查看愿望清单" ./bucket-list.sh intent "完成了 发布技能" ./bucket-list.sh intent "我们完成了什么" ``` ## Data The runtime data file is stored outside the skill folder: ```text <workspace>/data/bucket-list.json ``` The published `data/bucket-list.json` inside this skill is only an empty template. Do not publish personal wishes in the package. Canonical wish fields: - `id` - `content` - `category` - `status`: `pending`, `completed`, or `cancelled` - `createdAt` - `endedAt` - `endedBy` - `completionNote` - `cancelReason` - `timeline` ## Safety - The server binds to `127.0.0.1` only. - The server only accepts same-origin browser writes by default. - Writes are validated, size-limited, and saved atomically with a backup. - The CLI uses Node JSON parsing/writing instead of shell text edits. ## Boundaries - Do not treat ordinary tasks as wishes unless the user frames them as a wish, life goal, or bucket-list item. - Ask before recording sensitive or emotionally loaded content if the user's intent is unclear. - Strong negative emotion should receive care first; record or update wishes only after the user confirms.

path: _meta.json
content: ownerId: kn73jxegvkb2vf54gk6x3b2cc184bdvm
slug: bucket-list
version: 1.4.0
publishedAt: 1775540564617

path: bucket-list.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>愿望清单</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #1a1a2e;
            color: #eee;
            padding: 20px;
            min-height: 100vh;
        }
        .container { max-width: 600px; margin: 0 auto; }

        /* 人生进度 */
        .life-progress-section {
            background: #16213e;
            border-radius: 12px;
            padding: 16px;
            margin-bottom: 16px;
        }
        .life-progress-toggle {
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            font-size: 14px;
            color: #888;
        }
        .life-progress-toggle input { width: 16px; height: 16px; }
        .life-progress-content { display: none; margin-top: 16px; }
        .life-progress-content.show { display: block; }
        .life-progress-inputs {
            display: flex;
            gap: 8px;
            margin-bottom: 16px;
            flex-wrap: wrap;
        }
        .life-progress-inputs input {
            flex: 1;
            min-width: 80px;
            padding: 8px 12px;
            border: 1px solid #2a2a4a;
            border-radius: 8px;
            background: #1a1a2e;
            color: #eee;
            font-size: 14px;
        }
        .life-progress-inputs input:focus { outline: none; border-color: #f39c12; }
        .life-progress-inputs button {
            padding: 8px 16px;
            border: none;
            border-radius: 8px;
            background: #f39c12;
            color: #1a1a2e;
            font-weight: 600;
            cursor: pointer;
        }
        .life-progress-display { text-align: center; }
        .life-percent {
            font-size: 48px;
            font-weight: bold;
            color: #e94560;
            text-shadow: 0 0 20px rgba(233,69,96,0.3);
        }
        .life-label { font-size: 14px; color: #888; margin-bottom: 12px; }
        .life-bar {
            height: 6px;
            background: #2a2a4a;
            border-radius: 3px;
            overflow: hidden;
            margin-bottom: 12px;
        }
        .life-bar-inner {
            height: 100%;
            background: linear-gradient(90deg, #e94560, #f39c12);
            border-radius: 3px;
            transition: width 0.5s;
        }
        .life-stats {
            display: flex;
            justify-content: space-around;
            font-size: 13px;
            color: #666;
        }
        .life-stats span { color: #f39c12; font-weight: 600; }

        /* 统计 */
        .stats {
            text-align: center;
            margin-bottom: 16px;
            color: #888;
            font-size: 14px;
        }

        /* 区块 */
        .section {
            background: #16213e;
            border-radius: 12px;
            padding: 16px;
            margin-bottom: 16px;
        }
        .section-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 12px;
            padding-bottom: 8px;
            border-bottom: 1px solid #2a2a4a;
        }
        .section-title { font-size: 16px; font-weight: 600; }
        .section-title.pending { color: #f39c12; }
        .section-title.completed { color: #27ae60; }
        .section-title.cancelled { color: #e74c3c; }

        /* 愿望项 */
        .wish-item {
            display: flex;
            align-items: flex-start;
            padding: 10px 0;
            border-bottom: 1px solid #2a2a4a;
        }
        .wish-item:last-child { border-bottom: none; }
        .wish-content { flex: 1; }
        .wish-content .text {
            font-size: 15px;
            line-height: 1.4;
            cursor: pointer;
        }
        .wish-content .text:hover { color: #f39c12; }
        .wish-content .text.completed { text-decoration: line-through; color: #888; }
        .wish-content .meta {
            font-size: 12px;
            color: #666;
            margin-top: 4px;
        }
        .wish-content .note {
            font-size: 13px;
            color: #888;
            font-style: italic;
            margin-top: 6px;
            padding: 6px 10px;
            background: #1a1a2e;
            border-radius: 6px;
        }
        .wish-content .expand-hint {
            font-size: 11px;
            color: #f39c12;
            margin-top: 4px;
            cursor: pointer;
            display: inline-block;
        }
        .wish-content .expand-hint:hover { text-decoration: underline; }

        /* 时间线 */
        .timeline {
            display: none;
            margin-top: 12px;
            padding: 14px;
            background: #0f0f1a;
            border-radius: 8px;
            border-left: 3px solid #f39c12;
        }
        .timeline.show { display: block; }
        .timeline-entry {
            margin-bottom: 14px;
            padding-left: 12px;
            border-left: 2px solid #2a2a4a;
        }
        .timeline-entry:last-child { margin-bottom: 0; }
        .timeline-entry .entry-header {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 4px;
        }
        .timeline-entry .entry-icon { font-size: 14px; }
        .timeline-entry .entry-event { font-size: 13px; font-weight: 600; color: #f39c12; }
        .timeline-entry .entry-date { font-size: 11px; color: #666; }
        .timeline-entry .entry-note { font-size: 13px; color: #aaa; line-height: 1.5; margin-top: 4px; }

        /* 操作按钮 */
        .actions { display: flex; gap: 8px; margin-left: 8px; }
        .actions button {
            padding: 4px 10px;
            font-size: 12px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            transition: opacity 0.2s;
        }
        .actions button:hover { opacity: 0.8; }
        .actions .complete-btn { background: #27ae60; color: white; }
        .actions .cancel-btn { background: #e74c3c; color: white; }
        .actions .undo-btn { background: #444; color: #aaa; }

        /* 添加表单 */
        .add-form { display: flex; gap: 8px; margin-top: 12px; }
        .add-form input {
            flex: 1;
            padding: 10px 14px;
            border: 1px solid #2a2a4a;
            border-radius: 8px;
            background: #1a1a2e;
            color: #eee;
            font-size: 14px;
        }
        .add-form input:focus { outline: none; border-color: #f39c12; }
        .add-form button {
            padding: 10px 20px;
            background: #f39c12;
            color: #1a1a2e;
            border: none;
            border-radius: 8px;
            font-weight: 600;
            cursor: pointer;
            transition: background 0.2s;
        }
        .add-form button:hover { background: #e67e22; }

        /* 空状态 */
        .empty { text-align: center; padding: 30px; color: #666; }
        .empty-icon { font-size: 48px; margin-bottom: 12px; }

        /* 文件同步按钮 */
        .file-actions {
            display: flex;
            gap: 8px;
            margin-bottom: 16px;
            flex-wrap: wrap;
        }
        .file-actions button {
            padding: 8px 12px;
            font-size: 12px;
            border: 1px solid #2a2a4a;
            border-radius: 6px;
            background: #16213e;
            color: #888;
            cursor: pointer;
            transition: all 0.2s;
        }
        .file-actions button:hover { border-color: #f39c12; color: #f39c12; }
        .file-actions button.linked { border-color: #27ae60; color: #27ae60; }

        footer {
            text-align: center;
            margin-top: 24px;
            padding: 16px;
            color: #666;
            font-size: 12px;
        }
    </style>
</head>
<body>
    <div class="container">
        <!-- 人生进度（可选显示） -->
        <div class="life-progress-section">
            <label class="life-progress-toggle">
                <input type="checkbox" id="showLifeProgress" onchange="toggleLifeProgress()">
                <span>显示人生进度</span>
            </label>
            <div class="life-progress-content" id="lifeProgressContent">
                <div class="life-progress-inputs">
                    <input type="number" id="birthYear" placeholder="出生年份" min="1900" max="2026">
                    <input type="number" id="lifeExpectancy" placeholder="预期寿命" min="50" max="120" value="80">
                    <button onclick="updateLifeProgress()">计算</button>
                </div>
                <div class="life-progress-display" id="lifeProgressDisplay" style="display:none">
                    <div class="life-label">你的人生进度</div>
                    <div class="life-percent" id="lifePercent">--%</div>
                    <div class="life-bar"><div class="life-bar-inner" id="lifeBar" style="width:0%"></div></div>
                    <div class="life-stats">
                        <div>已度过 <span id="daysLived">--</span> 天</div>
                        <div>预期寿命 <span id="expectancy">--</span> 岁</div>
                    </div>
                </div>
            </div>
        </div>

        <!-- 统计 -->
        <div class="stats">我们一起完成了 <span id="completedCount">0</span> 个愿望</div>

        <!-- 文件同步按钮 -->
        <div class="file-actions">
            <button id="linkBtn" onclick="linkFile()">🔗 链接文件</button>
            <button id="unlinkBtn" onclick="unlinkFile()" style="display:none">🔗 取消链接</button>
            <button onclick="exportFile()">📤 导出</button>
            <button onclick="document.getElementById('importInput').click()">📥 导入</button>
            <input type="file" id="importInput" accept=".json" style="display:none" onchange="importFile(event)">
        </div>

        <!-- 待完成 -->
        <div class="section">
            <div class="section-header">
                <span class="section-title pending">⏳ 正在实现（<span id="pendingCount">0</span>）</span>
            </div>
            <div id="pendingList"></div>
            <form class="add-form" onsubmit="addWish(event)">
                <input type="text" id="newWishInput" placeholder="添加新愿望..." autocomplete="off">
                <button type="submit">+ 添加</button>
            </form>
        </div>

        <!-- 已完成 -->
        <div class="section">
            <div class="section-header">
                <span class="section-title completed">✅ 已完成（<span id="completedListCount">0</span>）</span>
            </div>
            <div id="completedList"></div>
        </div>

        <!-- 已取消 -->
        <div class="section">
            <div class="section-header">
                <span class="section-title cancelled">❌ 已取消（<span id="cancelledCount">0</span>）</span>
            </div>
            <div id="cancelledList"></div>
        </div>

        <footer>
            愿望清单 · 本地优先
        </footer>
    </div>

    <script>
        // ========== 数据存储 ==========
        const DATA_FILE = 'data/bucket-list.json';
        const DB_NAME = 'BucketListDB';
        const STORE_NAME = 'fileHandles';
        let db = null;
        let fileHandle = null;
        let wishData = { version: '1.0', wishes: [], createdAt: new Date().toISOString().split('T')[0] };

        // IndexedDB 初始化
        function initDB() {
            return new Promise((resolve, reject) => {
                const request = indexedDB.open(DB_NAME, 1);
                request.onerror = () => reject(request.error);
                request.onsuccess = () => { db = request.result; resolve(db); };
                request.onupgradeneeded = (e) => {
                    const database = e.target.result;
                    if (!database.objectStoreNames.contains(STORE_NAME)) {
                        database.createObjectStore(STORE_NAME);
                    }
                };
            });
        }

        // 从 IndexedDB 恢复 file handle
        async function restoreFileHandle() {
            if (!db) await initDB();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readonly');
                const store = tx.objectStore(STORE_NAME);
                const request = store.get('default');
                request.onsuccess = () => {
                    fileHandle = request.result || null;
                    if (fileHandle) {
                        document.getElementById('linkBtn').style.display = 'none';
                        document.getElementById('unlinkBtn').style.display = 'inline-block';
                        document.getElementById('linkBtn').classList.add('linked');
                    }
                    resolve(fileHandle);
                };
                request.onerror = () => reject(request.error);
            });
        }

        // 保存 file handle 到 IndexedDB
        async function saveFileHandle(handle) {
            if (!db) await initDB();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                const store = tx.objectStore(STORE_NAME);
                const request = store.put(handle, 'default');
                request.onsuccess = () => resolve();
                request.onerror = () => reject(request.error);
            });
        }

        // 链接文件
        async function linkFile() {
            try {
                [fileHandle] = await window.showSaveFilePicker({
                    suggestedName: 'bucket-list.json',
                    types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }]
                });
                await saveFileHandle(fileHandle);
                document.getElementById('linkBtn').style.display = 'none';
                document.getElementById('unlinkBtn').style.display = 'inline-block';
                document.getElementById('linkBtn').classList.add('linked');
                await syncToFile();
            } catch (e) {
                if (e.name !== 'AbortError') console.error('链接失败:', e);
            }
        }

        // 取消链接
        async function unlinkFile() {
            if (!db) await initDB();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                const store = tx.objectStore(STORE_NAME);
                const request = store.delete('default');
                request.onsuccess = () => {
                    fileHandle = null;
                    document.getElementById('linkBtn').style.display = 'inline-block';
                    document.getElementById('unlinkBtn').style.display = 'none';
                    document.getElementById('linkBtn').classList.remove('linked');
                    resolve();
                };
                request.onerror = () => reject(request.error);
            });
        }

        // 同步到文件
        async function syncToFile() {
            if (!fileHandle) return;
            try {
                const writable = await fileHandle.createWritable();
                await writable.write(JSON.stringify(wishData, null, 2));
                await writable.close();
            } catch (e) {
                console.error('写入失败:', e);
            }
        }

        // 导出文件
        async function exportFile() {
            const blob = new Blob([JSON.stringify(wishData, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'bucket-list.json';
            a.click();
            URL.revokeObjectURL(url);
        }

        // 导入文件
        async function importFile(event) {
            const file = event.target.files[0];
            if (!file) return;
            try {
                const text = await file.text();
                const data = JSON.parse(text);
                wishData = normalizeData(data);
                saveData();
                render();
            } catch (e) {
                alert('导入失败: ' + e.message);
            }
            event.target.value = '';
        }

        // ========== 愿望逻辑 ==========
        function loadData() {
            try {
                const xhr = new XMLHttpRequest();
                xhr.open('GET', DATA_FILE, false);
                xhr.send();
                if (xhr.status === 200) {
                    wishData = normalizeData(JSON.parse(xhr.responseText));
                }
            } catch (e) {
                console.log('初始化新数据');
            }
            render();
        }

        function saveData() {
            try {
                const xhr = new XMLHttpRequest();
                xhr.open('POST', DATA_FILE, false);
                xhr.setRequestHeader('Content-Type', 'application/json');
                xhr.send(JSON.stringify(wishData, null, 2));
                if (xhr.status < 200 || xhr.status >= 300) {
                    throw new Error(xhr.responseText || 'HTTP ' + xhr.status);
                }
            } catch (e) {
                console.error('保存失败:', e);
                alert('保存失败：' + e.message);
            }
            syncToFile();
        }

        function normalizeData(data) {
            if (!data || !Array.isArray(data.wishes)) {
                throw new Error('数据必须包含 wishes 数组');
            }
            return {
                version: data.version || '1.4',
                createdAt: data.createdAt || new Date().toISOString().split('T')[0],
                wishes: data.wishes
                    .filter(w => w && typeof w.content === 'string' && w.content.trim())
                    .map(w => ({
                        id: String(w.id || generateId()),
                        content: String(w.content || '').trim(),
                        category: String(w.category || '其他'),
                        status: ['pending', 'completed', 'cancelled'].includes(w.status) ? w.status : 'pending',
                        createdAt: String(w.createdAt || new Date().toISOString().split('T')[0]).slice(0, 10),
                        endedAt: w.endedAt ? String(w.endedAt).slice(0, 10) : null,
                        endedBy: w.endedBy || null,
                        completionNote: String(w.completionNote || ''),
                        cancelReason: String(w.cancelReason || ''),
                        timeline: Array.isArray(w.timeline) ? w.timeline : []
                    }))
            };
        }

        function render() {
            const pending = wishData.wishes.filter(w => w.status === 'pending');
            const completed = wishData.wishes.filter(w => w.status === 'completed');
            const cancelled = wishData.wishes.filter(w => w.status === 'cancelled');

            document.getElementById('pendingCount').textContent = pending.length;
            document.getElementById('completedListCount').textContent = completed.length;
            document.getElementById('cancelledCount').textContent = cancelled.length;
            document.getElementById('completedCount').textContent = completed.length;

            renderList('pendingList', pending, false, false);
            renderList('completedList', completed, true, false);
            renderList('cancelledList', cancelled, false, true);
        }

        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        function renderTimeline(timeline) {
            if (!timeline || timeline.length === 0) return '';
            return timeline.map(e => `
                <div class="timeline-entry">
                    <div class="entry-header">
                        <span class="entry-icon">${e.event === '完成' ? '✅' : e.event === '取消' ? '❌' : '📝'}</span>
                        <span class="entry-event">${escapeHtml(e.event || '更新')}</span>
                        <span class="entry-date">${escapeHtml(e.date || '')}</span>
                    </div>
                    ${e.note ? `<div class="entry-note">${escapeHtml(e.note)}</div>` : ''}
                </div>
            `).join('');
        }

        function renderList(containerId, wishes, showNote = false, isCancelled = false) {
            const container = document.getElementById(containerId);
            if (wishes.length === 0) {
                container.innerHTML = '<div class="empty">暂无</div>';
                return;
            }
            container.innerHTML = wishes.map(w => {
                const idArg = JSON.stringify(String(w.id));
                const endedText = w.endedAt ? ` · ${w.status === 'completed' ? '完成于' : '取消于'} ${escapeHtml(w.endedAt)}` : '';
                const note = w.completionNote || w.cancelReason || '';
                return `
                    <div class="wish-item">
                        <div class="wish-content ${w.status === 'completed' ? 'completed' : ''}">
                            <div class="text ${w.status === 'completed' ? 'completed' : ''}" onclick="toggleTimeline(this.parentElement)">${escapeHtml(w.content)}</div>
                            <div class="meta">添加于 ${escapeHtml(w.createdAt || '未知')}${endedText}</div>
                            ${note ? `<div class="note">${escapeHtml(note)}</div>` : ''}
                            <div class="expand-hint" onclick="toggleTimeline(this.parentElement)">查看详情 ▾</div>
                            <div class="timeline">${renderTimeline(w.timeline || [])}</div>
                        </div>
                        <div class="actions">
                            ${w.status === 'pending' ? `
                                <button class="complete-btn" onclick='completeWish(${idArg})'>完成</button>
                                <button class="cancel-btn" onclick='cancelWish(${idArg})'>取消</button>
                            ` : ''}
                            ${w.status === 'completed' ? `<button class="undo-btn" onclick='undoComplete(${idArg})'>撤销</button>` : ''}
                            ${w.status === 'cancelled' ? `<button class="undo-btn" onclick='undoCancel(${idArg})'>撤销</button>` : ''}
                        </div>
                    </div>
                `;
            }).join('');
        }

        function toggleTimeline(element) {
            const textEl = element.querySelector('.text');
            const tlEl = element.querySelector('.timeline');
            const hintEl = element.querySelector('.expand-hint');
            if (tlEl.classList.contains('show')) {
                tlEl.classList.remove('show');
                hintEl.textContent = '查看详情 ▾';
            } else {
                tlEl.classList.add('show');
                hintEl.textContent = '收起 ▴';
            }
        }

        function generateId() {
            return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
        }

        function addWish(e) {
            e.preventDefault();
            const input = document.getElementById('newWishInput');
            const content = input.value.trim();
            if (!content) return;

            const now = new Date().toISOString().split('T')[0];
            const wish = {
                id: generateId(),
                content: content,
                status: 'pending',
                createdAt: now,
                endedAt: null,
                endedBy: null,
                completionNote: '',
                timeline: [{ date: now, event: '添加愿望', note: content }]
            };

            wishData.wishes.push(wish);
            saveData();
            input.value = '';
            render();
        }

        function completeWish(id) {
            const wish = wishData.wishes.find(w => w.id === id);
            if (!wish) return;

            const note = wish.completionNote || prompt('添加完成备注（可选）：', '');
            if (note === null) return;

            const now = new Date().toISOString().split('T')[0];
            wish.status = 'completed';
            wish.endedAt = now;
            wish.endedBy = 'complete';
            wish.completionNote = note;
            wish.timeline = wish.timeline || [];
            wish.timeline.push({ date: now, event: '完成', note: note || '完成愿望' });

            saveData();
            render();
        }

        function undoComplete(id) {
            const wish = wishData.wishes.find(w => w.id === id);
            if (!wish) return;

            wish.status = 'pending';
            wish.endedAt = null;
            wish.endedBy = null;
            wish.completionNote = '';
            if (wish.timeline) {
                wish.timeline = wish.timeline.filter(e => e.event !== '完成');
            }

            saveData();
            render();
        }

        function cancelWish(id) {
            const wish = wishData.wishes.find(w => w.id === id);
            if (!wish) return;

            const reason = prompt('取消原因（可选）：', '');
            if (reason === null) return;

            const now = new Date().toISOString().split('T')[0];
            wish.status = 'cancelled';
            wish.endedAt = now;
            wish.endedBy = 'cancelled';
            wish.timeline = wish.timeline || [];
            wish.timeline.push({ date: now, event: '取消', note: reason || '取消愿望' });

            saveData();
            render();
        }

        function undoCancel(id) {
            const wish = wishData.wishes.find(w => w.id === id);
            if (!wish) return;

            wish.status = 'pending';
            wish.endedAt = null;
            wish.endedBy = null;
            if (wish.timeline) {
                wish.timeline = wish.timeline.filter(e => e.event !== '取消');
            }

            saveData();
            render();
        }

        // ========== 人生进度 ==========
        let birthYear = parseInt(localStorage.getItem('birthYear')) || null;
        let lifeExpectancy = parseInt(localStorage.getItem('lifeExpectancy')) || 80;
        let showLifeProgress = localStorage.getItem('showLifeProgress') === 'true';

        function initLifeProgress() {
            document.getElementById('showLifeProgress').checked = showLifeProgress;
            if (birthYear) document.getElementById('birthYear').value = birthYear;
            document.getElementById('lifeExpectancy').value = lifeExpectancy;
            if (showLifeProgress) {
                document.getElementById('lifeProgressContent').classList.add('show');
                if (birthYear) updateLifeProgress();
            }
        }

        function toggleLifeProgress() {
            showLifeProgress = document.getElementById('showLifeProgress').checked;
            localStorage.setItem('showLifeProgress', showLifeProgress);
            document.getElementById('lifeProgressContent').classList.toggle('show', showLifeProgress);
            if (showLifeProgress && birthYear) updateLifeProgress();
        }

        function updateLifeProgress() {
            const birth = parseInt(document.getElementById('birthYear').value);
            const life = parseInt(document.getElementById('lifeExpectancy').value) || 80;

            if (!birth || birth < 1900 || birth > 2026) {
                alert('请输入有效的出生年份');
                return;
            }

            birthYear = birth;
            lifeExpectancy = life;
            localStorage.setItem('birthYear', birthYear);
            localStorage.setItem('lifeExpectancy', lifeExpectancy);

            const now = new Date();
            const birthDate = new Date(birth, 0, 1);
            const endDate = new Date(birth + life, 0, 1);
            const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());

            const totalDays = Math.floor((endDate - birthDate) / (1000 * 60 * 60 * 24));
            const daysLived = Math.floor((today - birthDate) / (1000 * 60 * 60 * 24));
            const percent = Math.min(100, (daysLived / totalDays) * 100);

            document.getElementById('lifePercent').textContent = percent.toFixed(1) + '%';
            document.getElementById('lifeBar').style.width = percent + '%';
            document.getElementById('daysLived').textContent = daysLived.toLocaleString();
            document.getElementById('expectancy').textContent = life;
            document.getElementById('lifeProgressDisplay').style.display = 'block';
        }

        // ========== 初始化 ==========
        async function init() {
            try {
                await initDB();
                await restoreFileHandle();
            } catch (e) {
                console.log('File System Access API 不可用，使用 localStorage');
            }
            loadData();
            initLifeProgress();
        }

        init();
    </script>
</body>
</html>


path: bucket-list.sh
content: #!/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$SCRIPT_DIR/scripts/bucket-list-data.js" "$@"


path: data/bucket-list.json
content: {
  "version": "1.4",
  "wishes": [],
  "createdAt": "2026-04-15"
}


path: scripts/bucket-list-data.js
content: #!/usr/bin/env node
const fs = require('fs');
const path = require('path');

const VALID_STATUS = new Set(['pending', 'completed', 'cancelled']);

function getSkillDir() {
  return path.resolve(__dirname, '..');
}

function getWorkspaceDir() {
  return path.resolve(getSkillDir(), '..', '..');
}

function getDataFile() {
  return process.env.BUCKET_LIST_DATA_FILE || path.join(getWorkspaceDir(), 'data', 'bucket-list.json');
}

function today() {
  return new Date().toISOString().slice(0, 10);
}

function emptyData() {
  return { version: '1.4', wishes: [], createdAt: today() };
}

function generateId() {
  return `wish_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

function normalizeTimelineEntry(entry) {
  if (!entry || typeof entry !== 'object') return null;
  return {
    date: String(entry.date || today()).slice(0, 10),
    event: String(entry.event || '更新'),
    note: String(entry.note || ''),
  };
}

function normalizeWish(wish) {
  if (!wish || typeof wish !== 'object') return null;
  const status = VALID_STATUS.has(wish.status) ? wish.status : 'pending';
  return {
    id: String(wish.id || generateId()),
    content: String(wish.content || '').trim(),
    category: String(wish.category || '其他').trim() || '其他',
    status,
    createdAt: String(wish.createdAt || today()).slice(0, 10),
    endedAt: wish.endedAt ? String(wish.endedAt).slice(0, 10) : null,
    endedBy: wish.endedBy || null,
    completionNote: String(wish.completionNote || ''),
    cancelReason: String(wish.cancelReason || ''),
    timeline: Array.isArray(wish.timeline) ? wish.timeline.map(normalizeTimelineEntry).filter(Boolean) : [],
  };
}

function validateData(data) {
  if (!data || typeof data !== 'object') throw new Error('Data must be a JSON object.');
  if (!Array.isArray(data.wishes)) throw new Error('Data must contain a wishes array.');
  return {
    version: String(data.version || '1.4'),
    wishes: data.wishes.map(normalizeWish).filter((wish) => wish && wish.content),
    createdAt: String(data.createdAt || today()).slice(0, 10),
  };
}

function ensureDataFile() {
  const file = getDataFile();
  fs.mkdirSync(path.dirname(file), { recursive: true });
  if (!fs.existsSync(file)) writeData(emptyData(), { backup: false });
}

function readData() {
  ensureDataFile();
  return validateData(JSON.parse(fs.readFileSync(getDataFile(), 'utf8')));
}

function writeData(data, options = {}) {
  const normalized = validateData(data);
  const file = getDataFile();
  fs.mkdirSync(path.dirname(file), { recursive: true });
  if (options.backup !== false && fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
  const tmp = `${file}.${process.pid}.tmp`;
  fs.writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
  fs.renameSync(tmp, file);
  return normalized;
}

function addWish(content, category = '其他') {
  const trimmed = String(content || '').trim();
  if (!trimmed) throw new Error('Wish content is required.');
  const data = readData();
  const now = today();
  const wish = normalizeWish({
    id: generateId(),
    content: trimmed,
    category,
    status: 'pending',
    createdAt: now,
    timeline: [{ date: now, event: '添加愿望', note: trimmed }],
  });
  data.wishes.push(wish);
  writeData(data);
  return wish;
}

function findWish(data, keyword) {
  const query = String(keyword || '').trim();
  if (!query) throw new Error('Keyword is required.');
  return data.wishes.find((wish) => wish.id === query)
    || data.wishes.find((wish) => wish.content.includes(query));
}

function completeWish(keyword, note = '') {
  const data = readData();
  const wish = findWish(data, keyword);
  if (!wish) throw new Error(`没找到包含「${keyword}」的愿望`);
  const now = today();
  wish.status = 'completed';
  wish.endedAt = now;
  wish.endedBy = 'complete';
  wish.completionNote = String(note || '');
  wish.cancelReason = '';
  wish.timeline = wish.timeline || [];
  wish.timeline.push({ date: now, event: '完成', note: wish.completionNote || '完成愿望' });
  writeData(data);
  return wish;
}

function cancelWish(keyword, reason = '') {
  const data = readData();
  const wish = findWish(data, keyword);
  if (!wish) throw new Error(`没找到包含「${keyword}」的愿望`);
  const now = today();
  wish.status = 'cancelled';
  wish.endedAt = now;
  wish.endedBy = 'cancelled';
  wish.cancelReason = String(reason || '');
  wish.timeline = wish.timeline || [];
  wish.timeline.push({ date: now, event: '取消', note: wish.cancelReason || '取消愿望' });
  writeData(data);
  return wish;
}

function undoWish(keyword) {
  const data = readData();
  const wish = findWish(data, keyword);
  if (!wish) throw new Error(`没找到包含「${keyword}」的愿望`);
  wish.status = 'pending';
  wish.endedAt = null;
  wish.endedBy = null;
  wish.completionNote = '';
  wish.cancelReason = '';
  wish.timeline = (wish.timeline || []).filter((entry) => entry.event !== '完成' && entry.event !== '取消');
  writeData(data);
  return wish;
}

function printGroup(title, wishes) {
  console.log('');
  console.log(`${title}（${wishes.length}项）`);
  if (!wishes.length) {
    console.log('  （空）');
    return;
  }
  for (const wish of wishes) {
    const ended = wish.endedAt ? ` -> ${wish.endedAt}` : '';
    const note = wish.completionNote || wish.cancelReason;
    console.log(`  - ${wish.content}（${wish.category}）${wish.createdAt}${ended}`);
    if (note) console.log(`    ${note}`);
  }
}

function printView(data = readData()) {
  const pending = data.wishes.filter((wish) => wish.status === 'pending');
  const completed = data.wishes.filter((wish) => wish.status === 'completed');
  const cancelled = data.wishes.filter((wish) => wish.status === 'cancelled');
  console.log('愿望清单');
  printGroup('待完成', pending);
  printGroup('已完成', completed);
  printGroup('已取消', cancelled);
  const rate = data.wishes.length ? Math.round((completed.length * 100) / data.wishes.length) : 0;
  console.log(`总计：${data.wishes.length}个愿望，完成率 ${rate}%`);
}

function printAchievements(data = readData()) {
  const completed = data.wishes.filter((wish) => wish.status === 'completed');
  console.log('一起完成的成就');
  if (!completed.length) {
    console.log('  （暂无成就记录）');
    return;
  }
  const years = [...new Set(completed.map((wish) => (wish.endedAt || '').slice(0, 4)).filter(Boolean))].sort();
  for (const year of years) {
    console.log('');
    console.log(`${year}年：`);
    for (const wish of completed.filter((item) => (item.endedAt || '').startsWith(year))) {
      console.log(`  - ${wish.content}（${wish.endedAt || '未知日期'}）`);
    }
  }
  console.log('');
  console.log(`共 ${completed.length} 个愿望，跨越 ${years.length} 年`);
}

function recognizeIntent(input) {
  const text = String(input || '').trim();
  let match = text.match(/^添加愿望[：:]\s*(.+)$/);
  if (match) return ['add', match[1]];
  match = text.match(/^(?:完成了|完成)\s*(.+)$/);
  if (match) return ['complete', match[1]];
  match = text.match(/^(?:取消|不做)\s*(.+)$/);
  if (match) return ['cancel', match[1]];
  if (/查看愿望|愿望清单|看一下清单/.test(text)) return ['view'];
  if (/完成了什么|成就|回顾|一起完成/.test(text)) return ['achievements'];
  return ['help'];
}

function printHelp() {
  console.log('愿望清单 - 可用命令：');
  console.log('  add "愿望内容" ["分类"]');
  console.log('  complete "关键词或ID" ["完成备注"]');
  console.log('  cancel "关键词或ID" ["取消原因"]');
  console.log('  undo "关键词或ID"');
  console.log('  view');
  console.log('  achievements');
  console.log('  intent "添加愿望：..."');
}

function runCli(argv) {
  const [command = 'view', ...args] = argv;
  try {
    switch (command) {
      case 'add': {
        const wish = addWish(args[0], args[1]);
        console.log(`已记录愿望「${wish.content}」（${wish.category}）`);
        console.log(`添加时间：${wish.createdAt}`);
        break;
      }
      case 'complete': {
        const wish = completeWish(args[0], args[1]);
        console.log(`恭喜完成「${wish.content}」`);
        console.log(`完成时间：${wish.endedAt}`);
        break;
      }
      case 'cancel': {
        const wish = cancelWish(args[0], args[1]);
        console.log(`已取消愿望「${wish.content}」`);
        console.log(`取消时间：${wish.endedAt}`);
        break;
      }
      case 'undo': {
        const wish = undoWish(args[0]);
        console.log(`已撤销，愿望「${wish.content}」回到待完成`);
        break;
      }
      case 'view':
        printView();
        break;
      case 'achievements':
        printAchievements();
        break;
      case 'json':
        console.log(JSON.stringify(readData(), null, 2));
        break;
      case 'validate':
        writeData(readData(), { backup: false });
        console.log(`OK: ${getDataFile()}`);
        break;
      case 'intent':
        runCli(recognizeIntent(args.join(' ')));
        break;
      default:
        printHelp();
        break;
    }
  } catch (error) {
    console.error(`错误：${error.message}`);
    process.exitCode = 1;
  }
}

if (require.main === module) {
  runCli(process.argv.slice(2));
}

module.exports = {
  addWish,
  cancelWish,
  completeWish,
  emptyData,
  getDataFile,
  readData,
  validateData,
  writeData,
};


path: server.js
content: #!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const { getDataFile, readData, validateData, writeData } = require('./scripts/bucket-list-data');

const PORT = Number(process.env.BUCKET_LIST_PORT || 9999);
const HOST = '127.0.0.1';
const MAX_BODY_BYTES = 1024 * 1024;
const SKILL_DIR = __dirname;

function sendJson(res, status, data) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(`${JSON.stringify(data, null, 2)}\n`);
}

function isSameOrigin(req) {
  const origin = req.headers.origin;
  if (!origin) return true;
  return origin === `http://localhost:${PORT}` || origin === `http://127.0.0.1:${PORT}`;
}

function setCors(req, res) {
  const origin = req.headers.origin;
  if (origin && isSameOrigin(req)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}

function collectBody(req) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    req.on('data', (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        reject(new Error('Request body too large.'));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
    req.on('error', reject);
  });
}

async function handleData(req, res) {
  if (req.method === 'GET') {
    return sendJson(res, 200, readData());
  }

  if (req.method !== 'POST') {
    return sendJson(res, 405, { error: 'Method not allowed' });
  }

  if (!isSameOrigin(req)) {
    return sendJson(res, 403, { error: 'Only same-origin localhost writes are allowed' });
  }

  try {
    const body = await collectBody(req);
    const data = validateData(JSON.parse(body));
    return sendJson(res, 200, writeData(data));
  } catch (error) {
    return sendJson(res, 400, { error: error.message });
  }
}

function handleStatic(req, res) {
  const rawPath = req.url === '/' ? '/bucket-list.html' : decodeURIComponent(req.url.split('?')[0]);
  const filePath = path.resolve(SKILL_DIR, `.${rawPath}`);

  if (!filePath.startsWith(`${SKILL_DIR}${path.sep}`) && filePath !== path.join(SKILL_DIR, 'bucket-list.html')) {
    res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('Forbidden');
    return;
  }

  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
      res.end('Not found');
      return;
    }

    const ext = path.extname(filePath);
    const types = {
      '.html': 'text/html; charset=utf-8',
      '.js': 'application/javascript; charset=utf-8',
      '.css': 'text/css; charset=utf-8',
      '.json': 'application/json; charset=utf-8',
    };
    res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
    res.end(data);
  });
}

const server = http.createServer(async (req, res) => {
  setCors(req, res);
  if (req.method === 'OPTIONS') {
    res.writeHead(isSameOrigin(req) ? 204 : 403);
    res.end();
    return;
  }

  const pathname = req.url.split('?')[0];
  if (pathname === '/data/bucket-list.json' || pathname === '/data/bucket-list.json/') {
    await handleData(req, res);
    return;
  }

  handleStatic(req, res);
});

server.listen(PORT, HOST, () => {
  console.log('Bucket List Server running');
  console.log(`  GUI: http://localhost:${PORT}/`);
  console.log(`  Data: ${getDataFile()}`);
  console.log('  Access: 127.0.0.1 only');
});


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)