// Deterministic state mutation functions for linkedin-thought-leadership plugin. // Pure functions operate on string content (same pattern as week-rollover.mjs). // I/O wrapper (writeState) handles file reads/writes (same pattern as queue-manager.mjs). import { readFileSync, writeFileSync, renameSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { applyWeekRollover } from './week-rollover.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const HOME = process.env.HOME || process.env.USERPROFILE || ''; const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-thought-leadership.local.md'); function replaceField(content, field, value) { return content.replace( new RegExp(`^${field}: .*`, 'm'), `${field}: ${value}` ); } function isoWeekFromDate(dateStr) { const d = new Date(dateStr + 'T12:00:00Z'); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; } function daysBetween(dateA, dateB) { const a = new Date(dateA + 'T12:00:00Z').getTime(); const b = new Date(dateB + 'T12:00:00Z').getTime(); if (isNaN(a) || isNaN(b)) return null; return Math.abs(Math.round((b - a) / 86400000)); } function extractField(content, field) { const re = new RegExp(`^${field}: *"?([^"\\n]*)"?`, 'm'); const m = content.match(re); return m ? m[1].trim() : ''; } /** * Update post tracking fields deterministically. * @param {string} stateContent - Full state file content * @param {{ postDate: string, postTopic: string, hookText: string, charCount: number, format: string }} opts * @returns {{ content: string, changes: string[] } | null} */ export function updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, format }) { let content = stateContent; const changes = []; // 1. Update last_post_date content = replaceField(content, 'last_post_date', `"${postDate}"`); changes.push(`last_post_date → ${postDate}`); // 2. Update last_post_topic content = replaceField(content, 'last_post_topic', `"${postTopic}"`); changes.push(`last_post_topic → ${postTopic}`); // 3. Set first_post_date if null const existingFirst = extractField(content, 'first_post_date'); if (!existingFirst || existingFirst === 'null') { content = replaceField(content, 'first_post_date', `"${postDate}"`); changes.push(`first_post_date → ${postDate} (first post!)`); } // 4. Week rollover — check if ISO week changed const currentWeek = extractField(content, 'current_week'); const postWeek = isoWeekFromDate(postDate); const rollover = applyWeekRollover(content, currentWeek, postWeek); if (rollover) { content = rollover.content; changes.push(rollover.message); } // 5. Increment posts_this_week const currentPosts = parseInt(extractField(content, 'posts_this_week') || '0', 10); content = replaceField(content, 'posts_this_week', String(currentPosts + 1)); changes.push(`posts_this_week → ${currentPosts + 1}`); // 6. Update streak const lastPostDate = extractField(stateContent, 'last_post_date'); let currentStreak = parseInt(extractField(content, 'current_streak') || '0', 10); if (lastPostDate && lastPostDate !== 'null') { const gap = daysBetween(lastPostDate, postDate); if (gap !== null && gap <= 2) { currentStreak += 1; changes.push(`current_streak → ${currentStreak} (gap: ${gap}d)`); } else { currentStreak = 1; changes.push(`current_streak → 1 (gap: ${gap}d, reset)`); } } else { currentStreak = 1; changes.push('current_streak → 1 (first post)'); } content = replaceField(content, 'current_streak', String(currentStreak)); // 7. Update longest_streak if exceeded const longestStreak = parseInt(extractField(content, 'longest_streak') || '0', 10); if (currentStreak > longestStreak) { content = replaceField(content, 'longest_streak', String(currentStreak)); changes.push(`longest_streak → ${currentStreak}`); } // 8. Append to Recent Posts section const hookPreview = hookText.length > 60 ? hookText.slice(0, 57) + '...' : hookText; const entry = `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`; content = content.replace( /^(## Recent Posts\n\n?)/m, `$1${entry}\n` ); changes.push(`Recent Posts += ${postDate} "${hookPreview.slice(0, 30)}..."`); if (content === stateContent) return null; return { content, changes }; } /** * Remove Recent Posts entries older than maxAgeDays. * @param {string} stateContent - Full state file content * @param {number} [maxAgeDays=90] * @returns {{ content: string, pruned: number } | null} */ export function pruneContentHistory(stateContent, maxAgeDays = 90) { const today = new Date(); const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - maxAgeDays); const cutoffStr = cutoff.toISOString().slice(0, 10); // Find all Recent Posts entries const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m); if (!recentSection || !recentSection[1].trim()) return null; const sectionContent = recentSection[1]; let pruned = 0; const lines = sectionContent.split('\n'); const kept = []; for (const line of lines) { const dateMatch = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]/); if (dateMatch) { if (dateMatch[1] < cutoffStr) { pruned++; continue; } } kept.push(line); } if (pruned === 0) return null; const newSection = kept.join('\n'); const content = stateContent.replace(recentSection[1], newSection); return { content, pruned }; } /** * Update follower count and recalculate growth metrics. * @param {string} stateContent - Full state file content * @param {{ count: number, month: string }} opts * @returns {{ content: string, changes: string[] } | null} */ export function updateFollowerCount(stateContent, { count, month }) { let content = stateContent; const changes = []; const previousCount = parseInt(extractField(content, 'follower_count') || '0', 10); const delta = count - previousCount; // Update follower_count content = replaceField(content, 'follower_count', String(count)); changes.push(`follower_count → ${count} (${delta >= 0 ? '+' : ''}${delta})`); // Recalculate growth_rate_needed const target = parseInt(extractField(content, 'follower_target') || '10000', 10); const targetDate = extractField(content, 'target_date'); const remaining = target - count; if (targetDate && targetDate !== 'null' && targetDate !== '""') { const [tYear, tMonth] = targetDate.split('-').map(Number); const [mYear, mMonth] = month.split('-').map(Number); const monthsLeft = (tYear - mYear) * 12 + (tMonth - mMonth); const effectiveMonths = Math.max(1, monthsLeft); const rateNeeded = Math.ceil(remaining / effectiveMonths); content = replaceField(content, 'growth_rate_needed', String(rateNeeded)); changes.push(`growth_rate_needed → ${rateNeeded}/month`); } // Append to Milestone Log section const logEntry = `- [${month}] ${count} (${delta >= 0 ? '+' : ''}${delta})`; content = content.replace( /^(## Milestone Log\n)/m, `$1${logEntry}\n` ); changes.push(`Milestone Log += ${month}`); if (content === stateContent) return null; return { content, changes }; } /** * I/O wrapper: read state file, apply update function, write atomically. * @param {function(string): {content: string}|null} updateFn - Pure update function */ export function writeState(updateFn) { const content = readFileSync(STATE_FILE, 'utf-8'); const result = updateFn(content); if (!result) { console.log('No changes needed.'); return; } const tmpPath = STATE_FILE + '.tmp'; writeFileSync(tmpPath, result.content, 'utf-8'); renameSync(tmpPath, STATE_FILE); if (result.changes) { console.log('State updated:', result.changes.join(', ')); } else if (result.pruned !== undefined) { console.log(`Pruned ${result.pruned} old entries.`); } } // Standalone mode if (import.meta.url === `file://${process.argv[1]}`) { const args = process.argv.slice(2); if (args.includes('--update-post')) { const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; writeState(content => updatePostTracking(content, { postDate: getArg('--date') || new Date().toISOString().slice(0, 10), postTopic: getArg('--topic') || 'unknown', hookText: getArg('--hook') || '', charCount: parseInt(getArg('--chars') || '0', 10), format: getArg('--format') || 'post' })); } else if (args.includes('--prune')) { const days = parseInt(args[args.indexOf('--prune') + 1] || '90', 10); writeState(content => pruneContentHistory(content, days)); } else if (args.includes('--update-followers')) { const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; writeState(content => updateFollowerCount(content, { count: parseInt(getArg('--count') || '0', 10), month: getArg('--month') || new Date().toISOString().slice(0, 7) })); } else { console.log('Usage:'); console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post'); console.log(' node state-updater.mjs --prune [days]'); console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04'); } }