refactor(vault): Phase 3 — metadata convergence
- Backfill `created` frontmatter on 266 active notes (git date or mtime) - Normalize `status` to 4 values: draft/active/done/archived (11 notes) - Active/进行中/needs-review → active - archive → archived - 待执行/conditional → draft - 完成/accepted → done - Declare clipper boundary: 04_Archive/Inbox-Clippings/** exempt from migration - Update depth rule: max 3 → max 4 levels (new notes only) - Add metadata-converge.mjs script for reproducibility
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
const SKIP_CREATED = process.argv.includes('--skip-created')
|
||||
const SKIP_STATUS = process.argv.includes('--skip-status')
|
||||
|
||||
const STATUS_MAP = {
|
||||
active: 'active',
|
||||
Active: 'active',
|
||||
archive: 'archived',
|
||||
'进行中': 'active',
|
||||
'待执行': 'draft',
|
||||
'完成': 'done',
|
||||
'needs-review': 'active',
|
||||
conditional: 'draft',
|
||||
accepted: 'done',
|
||||
}
|
||||
|
||||
const PARA_DIRS = ['00_Inbox', '01_Projects', '02_Areas', '03_Resources']
|
||||
|
||||
function getActiveNotes() {
|
||||
const results = []
|
||||
for (const dir of PARA_DIRS) {
|
||||
try {
|
||||
const out = execSync(`find ${dir} -name '*.md' -type f`, { encoding: 'utf8' })
|
||||
results.push(...out.split('\n').filter(Boolean))
|
||||
} catch { /* dir may not exist */ }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function getGitCreatedDate(filepath) {
|
||||
try {
|
||||
const out = execSync(
|
||||
`git log --follow --format=%ai -- "${filepath}" | tail -1`,
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
if (out) return out.split(' ')[0]
|
||||
} catch { /* no git history */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function getFileMtime(filepath) {
|
||||
try {
|
||||
const out = execSync(`stat -c %Y "${filepath}"`, { encoding: 'utf8' }).trim()
|
||||
const d = new Date(parseInt(out) * 1000)
|
||||
return d.toISOString().split('T')[0]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatter(content) {
|
||||
if (!content.startsWith('---\n')) return null
|
||||
const end = content.indexOf('\n---', 4)
|
||||
if (end === -1) return null
|
||||
const fmText = content.slice(4, end)
|
||||
const lines = fmText.split('\n')
|
||||
const fm = {}
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^([a-zA-Z_-]+):\s*(.*)$/)
|
||||
if (m) fm[m[1]] = m[2]
|
||||
}
|
||||
return { fm, fmText, end }
|
||||
}
|
||||
|
||||
let createdCount = 0
|
||||
let statusCount = 0
|
||||
let noFrontmatterCount = 0
|
||||
|
||||
const notes = getActiveNotes()
|
||||
console.log(`Scanning ${notes.length} active notes...`)
|
||||
|
||||
for (const filepath of notes) {
|
||||
const content = readFileSync(filepath, 'utf8')
|
||||
const parsed = parseFrontmatter(content)
|
||||
|
||||
if (!parsed) {
|
||||
if (!SKIP_CREATED) {
|
||||
const date = getGitCreatedDate(filepath) || getFileMtime(filepath)
|
||||
if (date) {
|
||||
createdCount++
|
||||
if (DRY_RUN) {
|
||||
console.log(` +frontmatter: ${filepath} (created: ${date})`)
|
||||
} else {
|
||||
const newContent = `---\ncreated: ${date}\n---\n${content}`
|
||||
writeFileSync(filepath, newContent, 'utf8')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
noFrontmatterCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { fm, fmText, end } = parsed
|
||||
let newFmText = fmText
|
||||
let changed = false
|
||||
|
||||
if (!SKIP_CREATED && !fm.created) {
|
||||
const date = getGitCreatedDate(filepath) || getFileMtime(filepath)
|
||||
if (date) {
|
||||
newFmText = `created: ${date}\n${newFmText}`
|
||||
createdCount++
|
||||
changed = true
|
||||
if (DRY_RUN) console.log(` +created: ${filepath} (${date})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!SKIP_STATUS && fm.status) {
|
||||
const rawStatus = fm.status.replace(/^["']|["']$/g, '').trim()
|
||||
const normalized = STATUS_MAP[rawStatus]
|
||||
if (normalized && normalized !== rawStatus) {
|
||||
newFmText = newFmText.replace(
|
||||
new RegExp(`^status:\\s*${rawStatus.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm'),
|
||||
`status: ${normalized}`
|
||||
)
|
||||
statusCount++
|
||||
changed = true
|
||||
if (DRY_RUN) console.log(` ~status: ${filepath} (${rawStatus} → ${normalized})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && !DRY_RUN) {
|
||||
const newContent = `---\n${newFmText}${content.slice(end)}`
|
||||
writeFileSync(filepath, newContent, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n--- ${DRY_RUN ? 'DRY RUN' : 'APPLIED'} ---`)
|
||||
console.log(`Notes without frontmatter (skipped): ${noFrontmatterCount}`)
|
||||
console.log(`created added: ${createdCount}`)
|
||||
console.log(`status normalized: ${statusCount}`)
|
||||
Reference in New Issue
Block a user