Skip to content

Missing file: auto-link-people.cjs #46

Description

@DonWMasonIO

It's referenced in CLAUDE.md, but not present in the repo. Claude offered to build it aand came up with this:

``
#!/usr/bin/env node
/**

  • auto-link-people.cjs
  • Converts known people names to [[Name]] WikiLinks in vault markdown files.
  • Scans 05-Areas/People/{Internal,External,CPO_Network} for the registry.
  • Usage:
  • node .scripts/auto-link-people.cjs # Process single file
  • node .scripts/auto-link-people.cjs --today # Process today's key files
  • node .scripts/auto-link-people.cjs --dry-run # Preview without writing
  • Module usage:
  • const { autoLinkContent } = require('./.scripts/auto-link-people.cjs');
  • const result = autoLinkContent(markdownText, registry);
    */

const fs = require('fs');
const path = require('path');

const VAULT_ROOT = path.resolve(__dirname, '..');
const PEOPLE_DIRS = [
path.join(VAULT_ROOT, '05-Areas', 'People', 'Internal'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'External'),
path.join(VAULT_ROOT, '05-Areas', 'People', 'CPO_Network'),
];

/**

  • Build a registry of known people from the People directories.
  • Returns { fullNames: Set, firstNameToFull: Map, ownerName: string }
    */
    function buildRegistry() {
    const fullNames = new Set();
    const firstNameToFull = new Map(); // firstName -> fullName or null (ambiguous)

for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
fullNames.add(name);

  const firstName = name.split(' ')[0];
  if (firstNameToFull.has(firstName)) {
    // Multiple people share this first name — mark ambiguous
    firstNameToFull.set(firstName, null);
  } else {
    firstNameToFull.set(firstName, name);
  }
}

}

// Load owner name to skip self-linking
let ownerName = '';
const profilePath = path.join(VAULT_ROOT, 'System', 'user-profile.yaml');
if (fs.existsSync(profilePath)) {
const yaml = fs.readFileSync(profilePath, 'utf8');
const match = yaml.match(/^name:\s*"?([^"\n]+)"?/m);
if (match) ownerName = match[1].trim();
}

// Load aliases from Key Context lines like "Goes by X"
const aliases = new Map(); // alias -> fullName
for (const dir of PEOPLE_DIRS) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.md') || file === 'README.md') continue;
const name = file.replace(/.md$/, '');
const content = fs.readFileSync(path.join(dir, file), 'utf8');
const goesBy = content.match(/Goes by "?([^"\n]+)"?/i);
if (goesBy) {
const alias = goesBy[1].trim();
// Only add if the alias isn't ambiguous with another full name or first name
if (!fullNames.has(alias)) {
aliases.set(alias, name);
}
}
}
}

return { fullNames, firstNameToFull, ownerName, aliases };
}

/**

  • Detect unknown full names in text (e.g., "Jessica Jolly") — first names
  • that appear with an unrecognized last name. These first names should NOT
  • be auto-linked as standalone first names elsewhere in the same file.
    */
    function findPoisonedFirstNames(text, registry) {
    const poisoned = new Set();
    // Match capitalized "First Last" patterns
    const namePattern = /\b([A-Z][a-z]+)\s+([A-Z][a-z]+)\b/g;
    let m;
    while ((m = namePattern.exec(text)) !== null) {
    const full = ${m[1]} ${m[2]};
    if (!registry.fullNames.has(full)) {
    // Unknown full name — poison this first name for standalone matching
    if (registry.firstNameToFull.has(m[1])) {
    poisoned.add(m[1]);
    }
    }
    }
    return poisoned;
    }

/**

  • Process markdown content and return the auto-linked version.
  • Skips frontmatter, code blocks, and existing WikiLinks.
    */
    function autoLinkContent(text, registry) {
    if (!registry) registry = buildRegistry();

const { fullNames, firstNameToFull, ownerName, aliases } = registry;
const poisoned = findPoisonedFirstNames(text, registry);

const lines = text.split('\n');
const result = [];
let inFrontmatter = false;
let frontmatterCount = 0;
let inCodeBlock = false;

for (let i = 0; i < lines.length; i++) {
let line = lines[i];

// Track frontmatter (--- delimited)
if (line.trim() === '---') {
  if (i === 0 || (frontmatterCount === 1 && inFrontmatter)) {
    frontmatterCount++;
    inFrontmatter = frontmatterCount === 1;
    if (frontmatterCount === 2) inFrontmatter = false;
    result.push(line);
    continue;
  }
}
if (inFrontmatter) {
  result.push(line);
  continue;
}

// Track code blocks
if (line.trim().startsWith('```')) {
  inCodeBlock = !inCodeBlock;
  result.push(line);
  continue;
}
if (inCodeBlock) {
  result.push(line);
  continue;
}

// Process this line for name linking
line = linkNamesInLine(line, fullNames, firstNameToFull, ownerName, aliases, poisoned);
result.push(line);

}

return result.join('\n');
}

/**

  • Replace known names with WikiLinks in a single line.
  • Skips text already inside [[ ]] or markdown links.
    */
    function linkNamesInLine(line, fullNames, firstNameToFull, ownerName, aliases, poisoned) {
    // Split line into segments: WikiLinks and non-WikiLinks
    // This prevents double-linking already-linked names
    const segments = line.split(/([[[^\]]+]])/);

return segments.map(seg => {
// If this segment is already a WikiLink, leave it
if (seg.startsWith('[[') && seg.endsWith(']]')) return seg;

// Replace full names first (longest match first)
const sortedNames = [...fullNames].sort((a, b) => b.length - a.length);
for (const name of sortedNames) {
  if (name === ownerName) continue;
  // Word-boundary match for the full name, case-sensitive
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const re = new RegExp(`\\b${escaped}\\b`, 'g');
  seg = seg.replace(re, `[[${name}]]`);
}

// Replace aliases (e.g., "Alex" -> [[Grace Brown|Alex]])
for (const [alias, fullName] of aliases) {
  if (fullName === ownerName) continue;
  const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const re = new RegExp(`\\b${escaped}\\b`, 'g');
  // Only replace if not already inside a wikilink we just created
  seg = seg.replace(re, (match, offset) => {
    // Check if we're inside a [[ ]] from a prior replacement in this segment
    const before = seg.substring(0, offset);
    const openCount = (before.match(/\[\[/g) || []).length;
    const closeCount = (before.match(/\]\]/g) || []).length;
    if (openCount > closeCount) return match; // inside a wikilink
    return `[[${fullName}|${alias}]]`;
  });
}

// Replace unambiguous first names (not poisoned)
for (const [firstName, fullName] of firstNameToFull) {
  if (fullName === null) continue; // ambiguous
  if (fullName === ownerName) continue;
  if (poisoned.has(firstName)) continue;

  const escaped = firstName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const re = new RegExp(`\\b${escaped}\\b`, 'g');
  seg = seg.replace(re, (match, offset) => {
    const before = seg.substring(0, offset);
    const openCount = (before.match(/\[\[/g) || []).length;
    const closeCount = (before.match(/\]\]/g) || []).length;
    if (openCount > closeCount) return match;
    return `[[${fullName}|${firstName}]]`;
  });
}

return seg;

}).join('');
}

/**

  • Process a single file in-place.
    */
    function processFile(filePath, dryRun = false) {
    const abs = path.resolve(filePath);
    if (!fs.existsSync(abs)) {
    console.error(File not found: ${abs});
    return { file: abs, changes: 0 };
    }

const original = fs.readFileSync(abs, 'utf8');
const registry = buildRegistry();
const linked = autoLinkContent(original, registry);

if (original === linked) {
return { file: abs, changes: 0 };
}

// Count changes
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;

if (!dryRun) {
fs.writeFileSync(abs, linked, 'utf8');
}

return { file: abs, changes, dryRun };
}

/**

  • --today mode: process today's key files (daily plan, tasks, week priorities, recent meetings).
    */
    function processToday(dryRun = false) {
    const today = new Date().toISOString().slice(0, 10);
    const candidates = [
    path.join(VAULT_ROOT, '03-Tasks', 'Tasks.md'),
    path.join(VAULT_ROOT, '02-Week_Priorities', 'Week_Priorities.md'),
    ];

// Add today's meetings
const meetingsDir = path.join(VAULT_ROOT, '00-Inbox', 'Meetings');
if (fs.existsSync(meetingsDir)) {
for (const file of fs.readdirSync(meetingsDir)) {
if (file.startsWith(today) && file.endsWith('.md')) {
candidates.push(path.join(meetingsDir, file));
}
}
}

// Add today's journal
const journalPath = path.join(VAULT_ROOT, 'Journal', ${today}.md);
if (fs.existsSync(journalPath)) candidates.push(journalPath);

// Add today's daily plan if it exists in any common location
const dailyPlanDir = path.join(VAULT_ROOT, '02-Week_Priorities');
if (fs.existsSync(dailyPlanDir)) {
for (const file of fs.readdirSync(dailyPlanDir)) {
if (file.includes(today) && file.endsWith('.md')) {
candidates.push(path.join(dailyPlanDir, file));
}
}
}

const results = [];
const registry = buildRegistry();
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue;
const original = fs.readFileSync(filePath, 'utf8');
const linked = autoLinkContent(original, registry);
if (original !== linked) {
const origLinks = (original.match(/[[/g) || []).length;
const newLinks = (linked.match(/[[/g) || []).length;
const changes = newLinks - origLinks;
if (!dryRun) fs.writeFileSync(filePath, linked, 'utf8');
results.push({ file: filePath, changes, dryRun });
}
}

return results;
}

// --- CLI ---
if (require.main === module) {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const filtered = args.filter(a => a !== '--dry-run');

if (filtered.includes('--today')) {
const results = processToday(dryRun);
if (results.length === 0) {
console.log('No changes needed in today's files.');
} else {
for (const r of results) {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);
}
console.log(\nTotal: ${results.length} files, +${results.reduce((s, r) => s + r.changes, 0)} links);
}
} else if (filtered.length > 0) {
for (const filePath of filtered) {
const r = processFile(filePath, dryRun);
if (r.changes === 0) {
console.log(${path.relative(VAULT_ROOT, r.file)}: no changes);
} else {
const label = r.dryRun ? '[dry-run] ' : '';
console.log(${label}${path.relative(VAULT_ROOT, r.file)}: +${r.changes} links);
}
}
} else {
console.log('Usage:');
console.log(' node auto-link-people.cjs Process a single file');
console.log(' node auto-link-people.cjs --today Process today's key files');
console.log(' node auto-link-people.cjs --dry-run Preview without writing');
}
}

module.exports = { autoLinkContent, buildRegistry, processFile, processToday };
``

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions