Garmin Connect 训练计划批量清理

用于批量识别和删除 Garmin Connect 日历中指定月份的训练计划。

当前为 2025-11-22 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Garmin Connect 训练计划批量清理
// @namespace    http://tampermonkey.net/
// @version      1.0
// @license      MIT
// @description  用于批量识别和删除 Garmin Connect 日历中指定月份的训练计划。
// @author       您的用户名/昵称
// @match        https://connect.garmin.cn/modern/calendar*
// @match        https://connect.garmin.com/modern/calendar*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // 1. 数据存储和状态
    let cachedCalendarItems = [];
    let currentContext = null;

    // 2. 样式注入 (保持不变)
    const STYLES = `
        #garmin-batch-del-btn {
            background-color: #9c27b0; 
            color: white; 
            border: none; 
            padding: 6px 15px; 
            border-radius: 4px; 
            font-size: 14px; 
            font-weight: bold; 
            cursor: pointer; 
            display: inline-block;
            margin-left: 20px;
        }
        #garmin-batch-del-btn:disabled {
            background-color: #ccc;
            cursor: not-allowed;
        }
        /* ... 完整的 STYLES 保持不变 ... */
        #gc-modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.6); z-index: 9999; display: flex;
            justify-content: center; align-items: center; font-family: 'Open Sans', sans-serif;
        }
        #gc-modal-box {
            background: #fff; width: 500px; max-height: 80vh; border-radius: 8px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.3); display: flex; flex-direction: column;
            overflow: hidden;
        }
        #gc-modal-header {
            padding: 15px 20px; background: #f5f7fa; border-bottom: 1px solid #eee;
            display: flex; justify-content: space-between; align-items: center;
        }
        #gc-modal-title { font-size: 16px; font-weight: bold; color: #333; }
        #gc-close-btn { cursor: pointer; font-size: 20px; color: #999; }
        #gc-modal-body { padding: 0; overflow-y: auto; flex-grow: 1; background: #fff; }
        .gc-list-item {
            display: flex; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee;
            transition: background 0.2s; background: #fcf8ff;
        }
        .gc-list-item:hover { background-color: #f5f0ff; }
        .gc-checkbox { transform: scale(1.3); margin-right: 15px; cursor: pointer; }
        .gc-item-info { flex-grow: 1; }
        .gc-visual-tag {
            display: inline-block; width: 8px; height: 8px; border-radius: 50%; 
            margin-right: 6px; background-color: #9c27b0; 
            box-shadow: 0 0 4px #ce93d8;
        }
        .gc-item-title { font-size: 14px; font-weight: 600; color: #333; }
        .gc-item-detail { font-size: 12px; color: #888; margin-top: 3px; display: flex; justify-content: space-between;}
        #gc-modal-footer {
            padding: 15px 20px; border-top: 1px solid #eee; background: #fff;
            display: flex; justify-content: space-between; align-items: center;
        }
        .gc-btn { padding: 8px 20px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; }
        .gc-btn-cancel { background: #f0f0f0; color: #333; }
        .gc-btn-delete { background: #d9534f; color: #fff; }
        .gc-btn-delete:disabled { background: #f0ad4e; color: #fff; cursor: not-allowed; opacity: 0.7; }
        #gc-select-all-area { font-size: 14px; display: flex; align-items: center;}
    `;

    const styleSheet = document.createElement("style");
    styleSheet.innerText = STYLES;
    document.head.appendChild(styleSheet);
    // V1.0 修复:在内容脚本中,将样式注入到文档根元素更稳定
    // document.documentElement.appendChild(styleSheet);

    // 辅助函数:从页面的 meta 标签中获取 CSRF Token
    function getCsrfToken() {
        // 查找 name 为 "csrf-token" 的 meta 标签
        const metaTag = document.querySelector('meta[name="csrf-token"]');
        // 返回其 content 属性的值,如果找不到则返回 null
        return metaTag ? metaTag.content : null;
    }

    // 2. V28.0 最终修复: 从URL中提取年月上下文,修正月份偏差,并生成显示标题
    function getContextFromUrl(url) {
        const match = url.match(/\/year\/(\d{4})\/month\/(\d{1,2})/);
        
        if (match) {
            let year = parseInt(match[1]);
            let urlMonth = parseInt(match[2]); 
            let monthIndex = urlMonth; 

            // 特殊情况处理:如果 URL 是 /month/12,它返回的应该是下一年的 1 月数据 (JS 索引 0)
            if (urlMonth === 12) {
                monthIndex = 0; // 目标是下一年 1 月 (JS 索引 0)
                year += 1;
            }
            
            // 目标月份 (1-12)
            const displayMonth = monthIndex + 1; 
            const displayTitle = `${year}年${displayMonth}月`;

            console.log(`[V28.0 上下文] URL月参数: ${urlMonth} | 目标月份: ${displayTitle} | 目标JS月索引: ${monthIndex}`);
            
            return {
                year: year,
                monthIndex: monthIndex, // 0-11
                display: displayTitle    // V28.0 新增: 用于模态框标题
            };
        }
        return null;
    }



    function getContextFromDom() {
        const header = document.querySelector('.calendar-header');
        if (!header) return null;
        
        const headerText = header.querySelector('.calendar-date-wrapper .calendar-date') || header;
        
        let cnDateMatch = headerText.innerText.match(/(\d{1,2})月\s*(\d{4})/);
        if (cnDateMatch) {
              return { 
                source: 'DOM',
                year: parseInt(cnDateMatch[2], 10), 
                month: parseInt(cnDateMatch[1], 10), 
                monthIndex: parseInt(cnDateMatch[1], 10) - 1, 
                display: `${cnDateMatch[2]}年${cnDateMatch[1]}月` 
            };
        }

        cnDateMatch = headerText.innerText.match(/(\d{4}).*?(\d{1,2})月/);
        if (cnDateMatch) {
              return { 
                source: 'DOM',
                year: parseInt(cnDateMatch[1], 10), 
                month: parseInt(cnDateMatch[2], 10), 
                monthIndex: parseInt(cnDateMatch[2], 10) - 1, 
                display: `${cnDateMatch[1]}年${cnDateMatch[2]}月` 
            };
        }
        
        return null;
    }

    function isItemInCurrentMonth(item, currentContext) {
        if (!currentContext) {
             console.warn('[V25.0 日期] 缺少上下文信息。');
             return false;
        }
        
        const dateString = item.date;

        if (dateString) {
            // 尝试将日期字符串解析为 Date 对象
            const itemDate = new Date(dateString);
            
            // 检查日期对象是否有效
            if (isNaN(itemDate.getTime())) {
                console.warn(`[V25.0 日期] 无效日期字符串:${dateString}`);
                return false;
            }
            
            // 进行年和月匹配
            const isMatch = (itemDate.getFullYear() === currentContext.year) &&
                            (itemDate.getMonth() === currentContext.monthIndex);
            
            // V25.0 诊断日志 
            if (item.itemType === 'workout') {
                console.log(`[V25.0 日期诊断] ${item.title} (ID: ${item.id}) | 
                             项目日期: ${itemDate.getFullYear()}年${itemDate.getMonth() + 1}月 | 
                             目标月份: ${currentContext.year}年${currentContext.monthIndex + 1}月 | 
                             匹配: ${isMatch ? '✅' : '❌'}`);
            }

            return isMatch;
        } 
        // 某些项目可能没有 date 字段,一律排除
        return false;
    }

    function collectTargetItems(calendarItems, context) {
        const targetItems = [];
        const targetTypes = ['workout', 'trainingPlan'];

        calendarItems.forEach(item => {
            if (targetTypes.includes(item.itemType)) {
                if (isItemInCurrentMonth(item, context)) {
                    targetItems.push({
                        id: item.id,
                        title: item.title,
                        date: item.date || '未知日期',
                        itemType: item.itemType
                    });
                }
            }
        });

        targetItems.sort((a, b) => {
            if (a.date === '未知日期') return 1;
            if (b.date === '未知日期') return -1;
            return new Date(a.date) - new Date(b.date);
        });

        return targetItems;
    }

   // 4. 删除 API 调用 (V33.0 最终修复:使用 Garmin 专用的 connect-csrf-token 头部)
    async function deleteItem(id) {
        // getCsrfToken 函数保持不变,它依然从 <meta name="csrf-token"> 获取值
        const csrfToken = getCsrfToken();
        
        if (!csrfToken) {
            console.error('[V33.0 删除] 失败:页面中未找到 CSRF Token。');
            return false;
        }
        
        const domain = window.location.origin;
        const deleteUrl = `${domain}/gc-api/workout-service/schedule/${id}`;
        
        try {
            const response = await fetch(deleteUrl, {
                method: 'DELETE',
                // 只需要发送必要的头部
                headers: {
                    // *** 关键:使用 Garmin 专用的头部名称 ***
                    'connect-csrf-token': csrfToken, 
                    // Content-Type 保持不变,用于 DELETE 请求
                    'Content-Type': 'application/json' 
                }
            });
            
            if (response.status === 204) {
                console.log(`[V33.0 删除] 成功删除 ID: ${id} (状态码 204 No Content)`);
                return true;
            } else if (response.status === 403) {
                console.error(`[V33.0 删除] 失败:ID ${id} 遭遇 403 Forbidden。`);
                return false;
            } else {
                console.error(`[V33.0 删除] 失败:ID ${id} 状态码 ${response.status}。`);
                return false;
            }

        } catch (error) {
            console.error(`删除项目 ID: ${id} 时出错:`, error);
            return false;
        }
    }


    // 5. 模态框渲染和事件处理 (保持不变)
    function renderModal(items, monthTitle) {
        const old = document.getElementById('gc-modal-overlay');
        if (old) old.remove();

        const overlay = document.createElement('div');
        overlay.id = 'gc-modal-overlay';
        
        overlay.innerHTML = `
            <div id="gc-modal-box">
                <div id="gc-modal-header">
                    <div id="gc-modal-title">删除 ${monthTitle} 的训练计划 (共 ${items.length} 项)</div>
                    <div id="gc-close-btn">✕</div>
                </div>
                <div id="gc-modal-body">
                    </div>
                <div id="gc-modal-footer">
                    <div id="gc-select-all-area">
                        <input type="checkbox" id="gc-select-all" checked class="gc-checkbox" style="margin-right:8px">
                        <label for="gc-select-all">全选 (${items.length}/${items.length})</label>
                    </div>
                    <div>
                        <button class="gc-btn gc-btn-cancel" id="gc-btn-cancel">取消</button>
                        <button class="gc-btn gc-btn-delete" id="gc-btn-confirm">确认删除</button>
                    </div>
                </div>
            </div>
        `;

        document.body.appendChild(overlay);

        const listContainer = overlay.querySelector('#gc-modal-body');
        
        items.forEach(item => {
            const row = document.createElement('div');
            row.className = 'gc-list-item';
            
            let dateStr = item.date.substring(5, 10) || '--/--'; 
            
            row.innerHTML = `
                <input type="checkbox" class="gc-checkbox item-chk" data-id="${item.id}" checked>
                <div class="gc-item-info">
                    <div class="gc-item-title">
                        <span class="gc-visual-tag"></span>
                        ${item.title || '无标题训练'}
                    </div>
                    <div class="gc-item-detail">
                        <span>日期: ${dateStr}</span>
                        <span style="font-style: italic;">类型: ${item.itemType === 'trainingPlan' ? '计划' : '单次训练'} | ID: ${item.id}</span>
                    </div>
                </div>
            `;
            
            row.onclick = (e) => {
                const chk = row.querySelector('.item-chk');
                if (e.target !== chk) {
                    chk.checked = !chk.checked;
                    updateDeleteBtnState();
                }
            };
            row.querySelector('.item-chk').onclick = (e) => {
                e.stopPropagation();
                updateDeleteBtnState();
            };
            listContainer.appendChild(row);
        });

        const close = () => overlay.remove();
        overlay.querySelector('#gc-close-btn').onclick = close;
        overlay.querySelector('#gc-btn-cancel').onclick = close;

        const allChk = overlay.querySelector('#gc-select-all');
        const itemChks = overlay.querySelectorAll('.item-chk');
        
        allChk.onclick = () => {
            itemChks.forEach(chk => chk.checked = allChk.checked);
            updateDeleteBtnState();
        };

        function updateDeleteBtnState() {
            const checkedCount = overlay.querySelectorAll('.item-chk:checked').length;
            const totalCount = itemChks.length;
            const delBtn = overlay.querySelector('#gc-btn-confirm');
            const allLabel = overlay.querySelector('#gc-select-all-area label');
            
            delBtn.innerText = checkedCount > 0 ? `删除选中的 (${checkedCount})` : '请选择';
            delBtn.disabled = checkedCount === 0;
            allLabel.innerText = `全选 (${checkedCount}/${totalCount})`;
            
            if(checkedCount === totalCount) allChk.checked = true;
            else if(checkedCount === 0) allChk.checked = false;
        }

        overlay.querySelector('#gc-btn-confirm').onclick = async () => {
            const selectedChks = overlay.querySelectorAll('.item-chk:checked');
            const idsToDelete = Array.from(selectedChks).map(chk => chk.dataset.id);
            
            if (idsToDelete.length === 0) return;

            if (!confirm(`⚠️ 最终确认:\n真的要永久删除这 ${idsToDelete.length} 个训练计划吗?`)) return;

            const delBtn = overlay.querySelector('#gc-btn-confirm');
            const cancelBtn = overlay.querySelector('#gc-btn-cancel');
            cancelBtn.style.display = 'none'; 
            allChk.disabled = true;
            itemChks.forEach(c => c.disabled = true);

            let successCount = 0;
            
            for (let i = 0; i < idsToDelete.length; i++) {
                const id = idsToDelete[i];
                delBtn.innerText = `处理中... ${i + 1}/${idsToDelete.length}`;
                
                const success = await deleteItem(id);
                if (success) {
                    successCount++;
                    const itemRow = overlay.querySelector(`[data-id="${id}"]`).closest('.gc-list-item');
                    if(itemRow) {
                        itemRow.style.textDecoration = 'line-through';
                        itemRow.style.opacity = '0.5';
                    }
                }
                await new Promise(r => setTimeout(r, 100)); // 延时防封
            }

            alert(`清理完成!\n成功删除: ${successCount} 项。请刷新页面。`);
            overlay.remove();
            location.reload();
        };

        updateDeleteBtnState();
    }


    // 6. 按钮逻辑和 DOM 注入 (保持不变)
    function openSelectionModal() {
        let contextToUse = currentContext;
        let itemsToUse = cachedCalendarItems;

        if (!contextToUse) {
            contextToUse = getContextFromDom();
            
            if (!contextToUse) {
                alert('错误:无法确定当前的日历年月信息。请刷新页面重试或切换视图。');
                return;
            }
            
            alert(`警告 (V20.0): 脚本未能自动捕获 ${contextToUse.display} 的数据(API 拦截失败)。请确保当前月份存在训练计划后再尝试!`);
            return;
        }

        if (itemsToUse.length === 0) {
            alert(`当前 ${contextToUse.display} 未发现可删除的紫色训练计划。`);
            return;
        }
        
        renderModal(itemsToUse, contextToUse.display);
    }

    function checkViewAndInject() {
        const url = location.href;
        const isWeekView = url.includes('/week/');
        const isYearView = url.includes('/year/') && !url.includes('/month/');
        
        const headerContainer = document.querySelector('.calendar-header'); 
        let btn = document.getElementById('garmin-batch-del-btn');

        if (!isWeekView && !isYearView && headerContainer) {
            if (!btn) {
                btn = document.createElement('button');
                btn.id = 'garmin-batch-del-btn';
                btn.innerText = '🗑️ 批量删除计划 (V20.0)';
                btn.onclick = openSelectionModal;
                
                const leftToolbar = headerContainer.querySelector('.calendar-header-toolbar > div:first-child');
                
                if (leftToolbar) {
                     leftToolbar.appendChild(btn); 
                     console.log('V20.0: 按钮成功插入到左侧工具栏。');
                } else {
                     headerContainer.appendChild(btn); 
                     console.log('V20.0: 按钮插入到 headerContainer 末尾。');
                }

            }
            btn.style.display = 'inline-block';
            
            const btnDisabled = !(currentContext && cachedCalendarItems.length > 0);

            if (!currentContext) {
                 btn.disabled = false; 
            } else {
                 btn.disabled = btnDisabled; 
            }

        } else if (btn) {
            btn.style.display = 'none';
        }
    }
    
    // 7. V20.0 核心:数据处理函数
    // 7. V24.0 核心:数据处理函数 (增强日志)
    function processCalendarResponse(url, data) {
        const context = getContextFromUrl(url); 
        
        if (context) {
            const items = data.calendarItems || [];
            
            // V24.0 诊断日志 1: 打印所有捕获到的项目类型
            const allTypes = items.map(item => item.itemType);
            const uniqueTypes = [...new Set(allTypes)]; 
            
            console.log(`[V24.0 诊断] 捕获到 ${items.length} 个项目。所有类型:`, uniqueTypes);
            console.log(`[V24.0 诊断] 目标过滤类型: ['workout', 'trainingPlan']`);

            const targetItems = collectTargetItems(items, context);
            
            cachedCalendarItems = targetItems;
            currentContext = context;

            console.log(`V20.0 诊断: API 捕获成功。请求URL: ${url}`);
            console.log(`目标计划数: ${targetItems.length}`);
            
            checkViewAndInject();
        }
    }


    // 8. V20.0 核心:Fetch 和 XHR 双重拦截
   function hookFetch() {
        if (window.fetch.isHooked) return;
        
        const originalFetch = window.fetch;
        window.fetch = async function(...args) {
            const requestInfo = args[0]; // 可以是 URL 字符串或 Request 对象
            const url = requestInfo && typeof requestInfo === 'string' ? requestInfo : 
                        (requestInfo && requestInfo.url) ? requestInfo.url : 
                        null;

            // V23.0 Log 1: 记录每一个 fetch 调用的 URL
            console.log(`[V23.0 Fetch: Request] URL: ${url}`); 
            
            const response = await originalFetch.apply(this, args);
            
            // V23.0 检查:是否是日历数据请求
            const isCalendarDataRequest = url && 
                                          url.includes('/gc-api/calendar-service/') && 
                                          url.includes('/year/') && 
                                          url.includes('/month/');
            
            if (url && url.includes('/gc-api/calendar-service/')) {
                 console.log(`[V23.0 Fetch: Filter] 目标 URL? ${isCalendarDataRequest ? '✅ 是' : '❌ 否'} | URL: ${url}`);
            }

            if (isCalendarDataRequest) {
                if (response.status >= 200 && response.status < 300) {
                    try {
                        const clonedResponse = response.clone();
                        const data = await clonedResponse.json();
                        
                        if (data && data.calendarItems) {
                             // V23.0 Log 2: 确认数据已捕获
                            console.log(`[V23.0 Fetch: Success] 成功捕获日历数据 (${data.calendarItems.length} 项)!URL: ${url}`);
                            processCalendarResponse(url, data);
                        } else {
                            console.warn(`[V23.0 Fetch: Fail] URL通过过滤,但响应不包含 calendarItems。URL: ${url}`);
                        }
                    } catch (error) {
                        console.error('V23.0 Fetch 处理日历数据时发生 JSON 解析错误:', error);
                    }
                } else {
                    console.warn(`[V23.0 Fetch: Fail] URL通过过滤,但状态码非成功。状态: ${response.status} | URL: ${url}`);
                }
            }
            return response;
        };
        window.fetch.isHooked = true;
        console.log('V23.0: fetch 钩子已安装 (增强日志)。');
    }

   // XHR 拦截器 (用于兼容旧版 API 请求)
    function hookXHR() {
        if (window.XMLHttpRequest.isHooked) return;

        const originalOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function(method, url) {
            this._url = url;
            // V22.0 Log 1: 记录每一个 open 调用的 URL
            console.log(`[V22.0 XHR: Open] URL: ${url}`); 
            originalOpen.apply(this, arguments);
        };

        const originalSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.send = function() {
            // V22.0 改进:只拦截日历数据请求,并检查 URL 格式是否正确
            const isCalendarDataRequest = this._url && 
                                          this._url.includes('/gc-api/calendar-service/') && 
                                          this._url.includes('/year/') && 
                                          this._url.includes('/month/');
            
            // V22.0 Log 2: 记录过滤结果
            if (this._url && this._url.includes('/gc-api/calendar-service/')) {
                 console.log(`[V22.0 XHR: Filter] 目标 URL? ${isCalendarDataRequest ? '✅ 是' : '❌ 否'} | URL: ${this._url}`);
            }

            if (isCalendarDataRequest) {
                this.addEventListener('load', function() {
                    if (this.status >= 200 && this.status < 300) {
                        try {
                            const data = JSON.parse(this.responseText);
                            
                            if (data && data.calendarItems) {
                                // V22.0 Log 3: 确认数据已捕获
                                console.log(`[V22.0 XHR: Success] 成功捕获日历数据 (${data.calendarItems.length} 项)!URL: ${this._url}`);
                                processCalendarResponse(this._url, data);
                            } else {
                                console.warn(`[V22.0 XHR: Fail] URL通过过滤,但响应不包含 calendarItems。URL: ${this._url}`);
                            }
                        } catch (e) {
                            console.error(`[V22.0 XHR: Error] JSON解析失败。URL: ${this._url}`, e.message);
                            // console.error("原始响应文本 (供诊断):", this.responseText.substring(0, 100)); // 仅打印前100字符,避免污染控制台
                        }
                    } else {
                        console.warn(`[V22.0 XHR: Fail] URL通过过滤,但状态码非成功。状态: ${this.status} | URL: ${this._url}`);
                    }
                });
            }
            originalSend.apply(this, arguments);
        };
        window.XMLHttpRequest.isHooked = true;
        console.log('V22.0: XHR 钩子已安装 (增强日志)。');
    }
    

    // 9. 初始化和页面切换监听 (略有优化)
    let lastUrl = location.href;
    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            cachedCalendarItems = []; 
            currentContext = null; 
            checkViewAndInject();
        }
        checkViewAndInject();
    });

    function initExtension() {
        hookFetch();
        hookXHR(); 
        observer.observe(document.body, { subtree: true, childList: true });
        checkViewAndInject(); 
    }

    initExtension(); 
})();