用于批量识别和删除 Garmin Connect 日历中指定月份的训练计划。
当前为
// ==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();
})();