/** * Import BPS editorial calendars from Drive xlsx into documents (type=calendar). * Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26). * * Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx) * npx tsx scripts/export_bps_xlsx.ts * npx tsx scripts/import_bps_calendars.ts */ import { config } from 'dotenv'; import { resolve } from 'path'; import { writeFileSync } from 'fs'; import { execFileSync } from 'child_process'; config({ path: resolve(process.cwd(), '.env.local') }); import { serviceHeaders, supabaseUrl } from '../src/lib/agency'; import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView'; const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450'; /** * Explicit year/month for every editorial month sheet (incl. hidden). * Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26. */ const SHEET_YM: Record = { JULIO: '2025-07', AGOSTO: '2025-08', SEPTIEMBRE: '2025-09', OCTUBRE: '2025-10', NOVIEMBRE: '2025-11', DICIEMBRE: '2025-12', 'ENERO 2026': '2026-01', FEBRERO: '2026-02', MARZO: '2026-03', ABRIL: '2026-04', MAYO: '2026-05', JUNIO: '2026-06', 'JULIO 26': '2026-07', 'AGOSTO 26': '2026-08', 'SEPTIMEBRE 26': '2026-09', 'OCTUBRE 26': '2026-10', }; const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM)); function slugify(s: string) { return s .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 48); } function parseDayHeader(h: string): number | null { const raw = String(h || '').trim(); if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null; const m = raw.match(/(\d{1,2})\s*$/); if (!m) return null; const n = Number(m[1]); return n >= 1 && n <= 31 ? n : null; } type SheetRows = (string | number | null)[][]; /** Parse SEMANA/STORIES/POST grid into pieces. */ function parseWeekGrid( sheetName: string, rows: SheetRows, year: number, month: number ): RrssPiece[] { const pieces: RrssPiece[] = []; let dayCols: { col: number; day: number }[] = []; let kind: 'STORIES' | 'POST' | null = null; /** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */ let weekWraps = false; for (const row of rows) { if (!row || !row.some((c) => c != null && String(c).trim())) continue; const c0 = String(row[0] ?? '') .trim() .toUpperCase(); if ( c0.startsWith('SEMANA') || /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? '')) ) { dayCols = []; for (let col = 1; col < Math.min(row.length, 10); col++) { const day = parseDayHeader(String(row[col] ?? '')); if (day) dayCols.push({ col, day }); } weekWraps = dayCols.some( (d, i) => i > 0 && d.day < dayCols[i - 1].day && dayCols[i - 1].day >= 28 && d.day <= 7 ); kind = null; continue; } if (c0.startsWith('STORIES') || c0 === 'STORY') { kind = 'STORIES'; } else if (c0.startsWith('POST')) { kind = 'POST'; } else if (!kind) { continue; } for (const { col, day } of dayCols) { const raw = row[col]; if (raw == null) continue; const text = String(raw).trim(); if (!text || text === 'X' || /^columna/i.test(text)) continue; let y = year; let m = month; if (weekWraps && day >= 28) { m = month - 1; if (m < 1) { m = 12; y = year - 1; } } const mm = String(m).padStart(2, '0'); const dd = String(day).padStart(2, '0'); const date = `${y}-${mm}-${dd}`; const dt = new Date(`${date}T12:00:00Z`); if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue; const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`; pieces.push({ id, date, platform: 'Instagram', format: kind === 'STORIES' ? 'Stories' : 'Post', pillar: kind === 'STORIES' ? 'stories' : 'feed', title: text.split('\n')[0].slice(0, 120), body: text, status: 'planned', kind: kind === 'STORIES' ? 'story' : 'post', color: kind === 'STORIES' ? '#D9821F' : '#2071cf', }); } } const seen = new Set(); return pieces.filter((p) => { if (seen.has(p.id)) return false; seen.add(p.id); return true; }); } async function loadSheetsFromPython(): Promise< { name: string; rows: SheetRows; state: string }[] > { const py = ` import openpyxl, json wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True) out=[] month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}} for name in wb.sheetnames: ws=wb[name] keep = name in month_names or name == 'Calendario' if not keep: continue rows=[] # Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak blank=0 for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1): vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]] if not any(v is not None and str(v).strip() for v in vals): blank+=1 if blank>15 and i>30: break continue blank=0 rows.append(vals) out.append({'name': name, 'rows': rows, 'state': ws.sheet_state}) try: wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True) for name in wb2.sheetnames: ws=wb2[name] rows=[] blank=0 for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1): vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]] if not any(v is not None and str(v).strip() for v in vals): blank+=1 if blank>10: break continue blank=0 rows.append(vals) out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'}) except Exception: pass print(json.dumps(out, ensure_ascii=False, default=str)) `; const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 }); return JSON.parse(raw.toString('utf8')); } function parseStructuredCalendario(rows: SheetRows): Map { const byMonth = new Map(); for (const r of rows.slice(1)) { if (!r) continue; const dia = r[2]; const formato = String(r[3] ?? ''); const contenido = String(r[4] ?? '').trim(); const estado = String(r[6] ?? ''); if (!contenido && !formato) continue; let dateStr = ''; if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) { dateStr = dia.slice(0, 10); } else { continue; } const monthKey = dateStr.slice(0, 7); const isStory = /storie/i.test(formato); const isBlog = /blog/i.test(formato); const piece: RrssPiece = { id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`, date: dateStr, platform: isBlog ? 'Blog' : 'Instagram', format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post', pillar: isStory ? 'stories' : 'feed', title: (contenido || formato).split('\n')[0].slice(0, 120) || formato, body: contenido || undefined, status: /subido/i.test(estado) ? 'published' : /pendiente/i.test(estado) ? 'planned' : 'planned', kind: isStory ? 'story' : 'post', color: isStory ? '#D9821F' : '#2071cf', }; const list = byMonth.get(monthKey) || []; list.push(piece); byMonth.set(monthKey, list); } return byMonth; } function monthLabel(ym: string) { const [y, m] = ym.split('-').map(Number); const names = [ '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', ]; return `${names[m]} ${y}`; } function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan { const posts = pieces.filter((p) => p.kind !== 'story').length; const stories = pieces.filter((p) => p.kind === 'story').length; return { kind: 'rrss_month', month: ym, label: monthLabel(ym), hero: { eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS', title: `Calendario editorial · ${monthLabel(ym)}`, lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.', chips: [ `${posts} posts`, `${stories} stories`, 'Fuente: Google Drive', ], }, pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)), }; } async function upsertPlan(plan: RrssMonthPlan) { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); const base = supabaseUrl(); const title = `RRSS ${plan.label}`; const existing = await fetch( `${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`, { headers } ); const docs = (await existing.json()) as { id: string; title: string; content: unknown; }[]; for (const d of docs) { let c = d.content as RrssMonthPlan | string | null; if (typeof c === 'string') { try { c = JSON.parse(c) as RrssMonthPlan; } catch { c = null; } } const month = c && typeof c === 'object' ? c.month : null; const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`; if (month === plan.month || sameTitle) { await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' }, }); } } const row = { tenant_id: BPS_ID, type: 'calendar', title, content: plan, status: 'approved', }; const ins = await fetch(`${base}/rest/v1/documents`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation', }, body: JSON.stringify(row), }); if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`); const saved = (await ins.json()) as { id: string }[]; console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id); } function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] { const ids = new Set(a.map((p) => p.id)); return a.concat(b.filter((p) => !ids.has(p.id))); } async function main() { const sheets = await loadSheetsFromPython(); const byMonth = new Map(); const gridMonths = new Set(); // 1) All month tabs (hidden + visible) — primary source for (const sh of sheets) { const baseName = sh.name.replace(' (prov)', ''); const ym = SHEET_YM[baseName]; if (!ym) continue; const [year, month] = ym.split('-').map(Number); const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month); const cur = byMonth.get(ym) || []; if (sh.name.includes('(prov)') && pieces.length >= cur.length) { byMonth.set(ym, pieces); } else { byMonth.set(ym, mergePieces(cur, pieces)); } if (pieces.length) gridMonths.add(ym); console.log( `parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces` ); } // 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab // (May–Aug 2025 detail) or merge blog rows into existing months for (const sh of sheets) { if (sh.name !== 'Calendario') continue; const structured = parseStructuredCalendario(sh.rows as SheetRows); for (const [ym, pieces] of structured) { if (gridMonths.has(ym)) { // keep grid as source of truth; still add blog-only pieces const blogs = pieces.filter((p) => p.platform === 'Blog'); if (blogs.length) { byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs)); console.log(`calendario blogs → ${ym} +${blogs.length}`); } continue; } byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces)); console.log(`calendario only → ${ym} ${pieces.length}`); } } // Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025) const keepMonths = new Set(byMonth.keys()); writeFileSync( '/tmp/bps-cal/plans-summary.json', JSON.stringify( [...byMonth.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([m, p]) => ({ month: m, n: p.length })), null, 2 ) ); const headers = serviceHeaders(); const base = supabaseUrl(); if (headers && base) { const existing = await fetch( `${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`, { headers } ); const docs = (await existing.json()) as { id: string; content: unknown; }[]; for (const d of docs) { let c = d.content as { month?: string } | string | null; if (typeof c === 'string') { try { c = JSON.parse(c) as { month?: string }; } catch { c = null; } } const m = c && typeof c === 'object' ? c.month : null; if (!m) continue; // Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets if ( /^2025-0[2-4]$/.test(m) && keepMonths.has(m.replace('2025', '2026')) ) { await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' }, }); console.log('deleted stale', m, d.id); } } } for (const [ym, pieces] of [...byMonth.entries()].sort()) { if (!pieces.length) continue; await upsertPlan(buildPlan(ym, pieces)); } console.log( 'months', [...byMonth.keys()].sort().join(', '), 'total pieces', [...byMonth.values()].reduce((s, p) => s + p.length, 0) ); } main().catch((e) => { console.error(e); process.exit(1); });