ktg-plugin-marketplace/plugins/linkedin-thought-leadership/hooks/scripts/state-updater.mjs
Kjell Tore Guttormsen aa5cca9cf6 feat(linkedin): add state-updater.mjs — deterministic state mutations with tests
Pure functions for post tracking (streak, week rollover, first_post_date),
content history pruning, and follower count updates. 19 tests green.
Follows week-rollover.mjs pattern (pure functions) + queue-manager.mjs
pattern (I/O wrapper with atomic writes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 00:40:16 +02:00

253 lines
9.5 KiB
JavaScript

// 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');
}
}