Skip to content

Instantly share code, notes, and snippets.

@fangwangme
Last active December 31, 2025 19:04
Show Gist options
  • Select an option

  • Save fangwangme/3872bc25b5f5a4d69a108a232d0fd9d5 to your computer and use it in GitHub Desktop.

Select an option

Save fangwangme/3872bc25b5f5a4d69a108a232d0fd9d5 to your computer and use it in GitHub Desktop.
Obsidian Templater Script: Convert Daily/Weekly notes to static Markdown for AI/RAG (Supports Tasks & Dataview)

<%* // 🛡️ 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); } %>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment