<%* // 🛡️ v28:Weekly 语境重构版 (引入 Time-Window Scoping 引擎) try { // ================= 1. 配置区域 ================= const EXTERNAL_BACKUP_ROOT = "/Users/fangwang/project/life/ObsidianStaticBackup/PeriodicNotes";
// ================= 2. 交互模式 =================
const modes = [
"📅 备份【本周】 (Current Week)",
"⏮️ 备份【上一周】 (Last Week)",
"🔍 备份【指定日期】 (Specific Date)",
"📚 备份【指定年份】 (Full Year)"
];
const selectedMode = await tp.system.suggester(modes, ["current", "last", "specific", "year"]);
if (!selectedMode) return;
let weeksToProcess = [];
if (selectedMode === "current") {
weeksToProcess.push(moment());
} else if (selectedMode === "last") {
weeksToProcess.push(moment().subtract(1, 'weeks'));
} else if (selectedMode === "specific") {
const inputStr = await tp.system.prompt("输入日期 (YYYY-MM-DD)", moment().format("YYYY-MM-DD"));
if (!inputStr) return;
weeksToProcess.push(moment(inputStr, ["YYYY-MM-DD", "YYYY-Www"], true));
} else if (selectedMode === "year") {
const confirm = await tp.system.prompt("确认年份?", moment().format("gggg"));
if (!confirm) return;
const targetYear = parseInt(confirm);
const totalWeeks = moment(`${targetYear}-02-01`).isoWeeksInYear();
for (let w = 1; w <= totalWeeks; w++) {
weeksToProcess.push(moment().year(targetYear).isoWeek(w));
}
}
// ================= 3. 依赖检查 =================
const fs = require('fs');
const path = require('path');
const dv = app.plugins.plugins.dataview?.api;
if (!dv) throw new Error("Dataview 插件未启用");
// ================= 4. 辅助函数 =================
const safeMoment = (d) => {
if (!d) return null;
if (moment.isMoment(d)) return d;
if (d.toJSDate) return moment(d.toJSDate());
if (d instanceof Date) return moment(d);
const m = moment(d);
return m.isValid() ? m : null;
};
const parseTargetDate = (s) => {
if(!s) return moment();
const lower = s.trim().toLowerCase();
if(lower==='today') return moment().startOf('day');
if(lower==='tomorrow') return moment().add(1,'d').startOf('day');
if(lower==='yesterday') return moment().subtract(1,'d').startOf('day');
return moment(s, ["YYYY-MM-DD"]);
};
const extractDateFromText = (text, type) => {
if (!text || typeof text !== 'string') return null;
let regex;
if (type === 'due') regex = /📅\s*(\d{4}-\d{2}-\d{2})/;
else if (type === 'done') regex = /✅\s*(\d{4}-\d{2}-\d{2})/;
else if (type === 'cancelled') regex = /❌\s*(\d{4}-\d{2}-\d{2})/;
else if (type === 'scheduled') regex = /⏳\s*(\d{4}-\d{2}-\d{2})/;
else if (type === 'start') regex = /🛫\s*(\d{4}-\d{2}-\d{2})/;
else return null;
const match = text.match(regex);
return match ? moment(match[1]) : null;
};
// ================= 5. 核心引擎:Context Scoping (关键升级) =================
// 这里的逻辑对应反馈中的 "Layer 1: Weekly = 时间窗口裁剪任务集"
const getScopedTasks = (allTasks, filename) => {
// 1. 判断是否为 Weekly Note (格式: YYYY-Www)
const weeklyMatch = filename.match(/^(\d{4})-W(\d{2})$/);
if (!weeklyMatch) {
// 如果是 Daily Note 或其他,不做特殊裁剪,返回全部任务供 parseAndFilter 处理
// (Daily 的查询通常很精确,不需要 scope 介入)
return { tasks: allTasks, scope: "Global" };
}
// 2. 如果是 Weekly Note,计算时间窗口
const year = parseInt(weeklyMatch[1]);
const week = parseInt(weeklyMatch[2]);
// 计算 ISO 周的起止时间
const weekStart = moment().year(year).isoWeek(week).startOf('isoWeek');
const weekEnd = moment().year(year).isoWeek(week).endOf('isoWeek');
// 3. 执行裁剪 (Filtering)
const scoped = allTasks.filter(t => {
// 获取各项日期
const due = safeMoment(t.due) || extractDateFromText(t.text, 'due');
const done = safeMoment(t.completion) || extractDateFromText(t.text, 'done');
const cancelled = safeMoment(t.cancelled); // Dataview 任务对象可能有此字段
const created = safeMoment(t.created);
// A. 本周发生的事 (Active Context)
// 只要 Due, Done, Cancelled, Created 任意一个落在本周区间,保留
if (due && due.isBetween(weekStart, weekEnd, 'day', '[]')) return true;
if (done && done.isBetween(weekStart, weekEnd, 'day', '[]')) return true;
if (cancelled && cancelled.isBetween(weekStart, weekEnd, 'day', '[]')) return true;
if (created && created.isBetween(weekStart, weekEnd, 'day', '[]')) return true;
// B. 历史遗留 (Backlog Context)
// 条件:未完成 (status != x, X, -) 且 截止日期 < 本周开始
const isCompleted = (t.status === 'x' || t.status === 'X' || t.status === '-');
if (!isCompleted && due && due.isBefore(weekStart, 'day')) {
return true;
}
// 其他无关任务(如下周的任务、去年的已完成任务)全部丢弃
return false;
});
return { tasks: scoped, scope: `Weekly (${weekStart.format('MM-DD')}~${weekEnd.format('MM-DD')})` };
};
// ================= 6. 解析引擎 (Tasks Logic) =================
const evaluateCondition = (task, conditionStr) => {
let cleanStr = conditionStr.split('#')[0].trim();
cleanStr = cleanStr.replace(/^\(+|\)+$/g, '').trim();
const lowerStr = cleanStr.toLowerCase();
if (!cleanStr) return true;
// 状态
if (lowerStr.includes('status.type is done')) return (task.status === 'x' || task.status === 'X');
if (lowerStr.includes('status.type is cancelled')) return (task.status === '-');
if (lowerStr === 'not done') return task.status !== 'x' && task.status !== 'X' && task.status !== '-';
if (lowerStr === 'done') return task.status === 'x' || task.status === 'X';
if (lowerStr === 'has due date') return !!(safeMoment(task.due) || extractDateFromText(task.text, 'due'));
// 日期 (长词优先)
const regex = /(due|done|cancelled|created|scheduled|start)\s+(on\s+or\s+before|on\s+or\s+after|before|after|on)\s+(.+)/i;
const dateMatch = cleanStr.match(regex);
if (dateMatch) {
const field = dateMatch[1].toLowerCase();
const op = dateMatch[2].toLowerCase().replace(/\s+/g, ' ');
const dateVal = dateMatch[3].trim();
let taskDate = safeMoment(task[field]);
if (!taskDate && field !== 'created') taskDate = extractDateFromText(task.text, field);
if (!taskDate && field === 'created') taskDate = safeMoment(task.created);
if (!taskDate) return false;
const targetDate = parseTargetDate(dateVal);
if (!targetDate.isValid()) return false;
if (op === 'before') return taskDate.isBefore(targetDate, 'day');
if (op === 'after') return taskDate.isAfter(targetDate, 'day');
if (op === 'on') return taskDate.isSame(targetDate, 'day');
if (op === 'on or before') return taskDate.isSameOrBefore(targetDate, 'day');
if (op === 'on or after') return taskDate.isSameOrAfter(targetDate, 'day');
}
return true;
};
const parseAndFilter = (scopedTasks, queryText) => {
const lines = queryText.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
const sorters = [];
const filterLines = [];
lines.forEach(l => {
if (l.toLowerCase().startsWith('sort by')) {
const sortMatch = l.match(/^sort by (due|done|priorityName|priority|created)( reverse)?$/i);
if (sortMatch) sorters.push({ field: sortMatch[1], reverse: !!sortMatch[2] });
} else if (!l.toLowerCase().startsWith('group by') && !l.toLowerCase().startsWith('hide') && !l.toLowerCase().startsWith('short')) {
filterLines.push(l);
}
});
// 核心过滤
let result = scopedTasks.filter(t => {
return filterLines.every(line => {
const orParts = line.split(/ OR /i);
return orParts.some(part => {
const andParts = part.split(/ AND /i);
return andParts.every(sub => evaluateCondition(t, sub));
});
});
});
// 排序
if (sorters.length > 0) {
result = result.sort((a, b) => {
for (let s of sorters) {
let valA = 0, valB = 0;
const fieldName = s.field.toLowerCase();
const getVal = (item, f) => {
let v = safeMoment(item[f]);
if (!v) v = extractDateFromText(item.text, f);
return v ? v.valueOf() : 0;
};
if (fieldName.includes('priority')) {
const txtA = a.text || "";
const txtB = b.text || "";
const p = (txt) => txt.includes('🔺')?5:txt.includes('⏫')?4:txt.includes('🔼')?3:txt.includes('🔽')?1:2;
valA = p(txtA); valB = p(txtB);
} else {
const defaultVal = (fieldName === 'due') ? 9999999999999 : 0;
valA = getVal(a, fieldName) || defaultVal;
valB = getVal(b, fieldName) || defaultVal;
}
let res = (valA < valB) ? -1 : (valA > valB) ? 1 : 0;
if (fieldName.includes('priority')) res = valA - valB;
if (s.reverse) res = -res;
if (res !== 0) return res;
}
return 0;
});
}
return result;
};
const renderTask = (t) => {
let status = t.status || ' ';
let text = t.text ? t.text.replace(/[\r\n]+/g, " ") : "";
let linkPath = t.path ? t.path.replace(/\.md$/, '') : "";
return `- [${status}] ${text} *(📂 [[${linkPath}]] )*`;
};
// ================= 7. 执行流程 =================
const getSmartOutputFolder = (filename) => {
if (/^\d{4}-\d{2}-\d{2}$/.test(filename)) {
const m = moment(filename);
return path.join(EXTERNAL_BACKUP_ROOT, m.format("YYYY"), "Daily", m.format("MM"));
} else if (/^\d{4}-W\d{2}$/.test(filename)) {
const year = filename.split("-")[0];
return path.join(EXTERNAL_BACKUP_ROOT, year, "Weekly");
}
return path.join(EXTERNAL_BACKUP_ROOT, "Others");
};
const processFile = async (fileBasename) => {
let file = app.vault.getMarkdownFiles().find(f => f.basename === fileBasename);
if (!file) return false;
const outputFolder = getSmartOutputFolder(fileBasename);
if (!fs.existsSync(outputFolder)) fs.mkdirSync(outputFolder, { recursive: true });
let content = await app.vault.read(file);
// --- 核心:获取 Scope 裁剪后的任务池 ---
// 每次重新获取全库任务,保证状态最新
const allTasks = dv.pages('""').file.tasks;
const { tasks: scopedTasks, scope: scopeName } = getScopedTasks(allTasks, fileBasename);
content = content.replace(/```tasks\n([\s\S]*?)\n```/gi, (match, query) => {
try {
// 使用裁剪后的 scopedTasks 进行过滤
const tasks = parseAndFilter(scopedTasks, query);
if (tasks.length === 0) {
// 友好的 Static Render 提示 (遵循反馈建议)
return `> [!INFO] Static Render Scope: ${scopeName}\n> No tasks matched in this time window.`;
}
return tasks.map(renderTask).join('\n');
} catch (e) { return `> [Error] ${e.message}`; }
});
const asyncReplacer = async (str, regex, asyncFn) => {
const promises = [];
str.replace(regex, (match, ...args) => promises.push(asyncFn(match, ...args)));
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift());
};
content = await asyncReplacer(content, /```dataview\n([\s\S]*?)\n```/gi, async (match, query) => {
const res = await dv.queryMarkdown(query, file.path);
return res.successful ? res.value : `> [Error] ${res.error}`;
});
const header = `---\nbackup_time: ${moment().format()}\noriginal: "${file.path}"\nstatic_scope: "${scopeName}"\n---\n\n`;
fs.writeFileSync(path.join(outputFolder, `${fileBasename}.md`), header + content);
return true;
};
// ================= 8. 循环处理 =================
let totalFiles = 0;
for (let baseDate of weeksToProcess) {
let startOfWeek = baseDate.clone().startOf('isoWeek');
for (let i = 0; i < 7; i++) {
let dayObj = startOfWeek.clone().add(i, 'd');
let dailyName = dayObj.format("YYYY-MM-DD");
if (await processFile(dailyName)) totalFiles++;
}
let weeklyName = baseDate.format("gggg-[W]ww");
if (await processFile(weeklyName)) totalFiles++;
}
new Notice(`✅ 备份完成!(Weekly Scope Enabled)\n共处理: ${totalFiles} 个文件`);
} catch (err) {
new Notice(❌ 错误: ${err.message});
console.error(err);
}
%>