/** * Import editorial RRSS calendars from Drive export folder into documents. * * npx tsx scripts/import_drive_calendars_batch.ts * * Source: /Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001 * Months per client as requested by ops (Oct 2026). */ import { config } from 'dotenv'; import { resolve } from 'path'; 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 ROOT = '/Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001'; type SheetRows = (string | number | null)[][]; type ClientJob = { slug: string; brand: string; file: string; /** sheet name → year-month */ sheets: Record; /** if set, only upsert these months (others parsed ignored) */ onlyMonths: string[]; }; const JOBS: ClientJob[] = [ { slug: 'bps', brand: 'Blue Parrot School', file: `${ROOT}/BLUE PARROT/CALENDARIO Blue Parrot School.xlsx`, sheets: { 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', }, onlyMonths: [ '2025-05', '2025-06', '2025-07', '2025-08', '2025-09', '2025-10', '2025-11', '2025-12', '2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10', ], }, { slug: 'brava-dermoestetica', brand: 'Brava Dermoestética', file: `${ROOT}/BRAVA/CALENDARIO RRSS BRAVA.xlsx`, sheets: { 'ABRIL 2026': '2026-04', 'MAYO 2026': '2026-05', 'JUNIO 2026': '2026-06', 'JULIO 2026': '2026-07', 'AGOSTO 2026': '2026-08', 'SEPTIEMBRE 2026': '2026-09', 'Octubre 2026': '2026-10', }, onlyMonths: [ '2026-04', '2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10', ], }, { slug: 'talento-y-deporte', brand: 'Talento y Deporte', file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`, sheets: { AGOSTO: '2026-08', SEPTIEMBRE: '2026-09', OCTUBRE26: '2026-10', }, onlyMonths: ['2026-08', '2026-09', '2026-10'], }, { slug: 'halal-safety', brand: 'Halal Safety', file: `${ROOT}/HALAL/CALENDARIO Halal.xlsx`, sheets: { 'Hoja 1': '2026-10' }, onlyMonths: ['2026-10'], }, { slug: 'noberplast', brand: 'Noberplast', file: `${ROOT}/NOBERPLAST/CALENDARIO NOBERPLAST.xlsx`, sheets: { 'Hoja 1': '2026-10' }, onlyMonths: ['2026-10'], }, { slug: '4ventures-hub', brand: '4Ventures', file: `${ROOT}/4VENTURES/CALENDARIO 4VENTURES.xlsx`, sheets: { JULIO: '2026-07', AGOSTO: '2026-08', 'SEPTIEMBRE 2026': '2026-09', 'OCTUBRE 2026': '2026-10', }, onlyMonths: ['2026-07', '2026-08', '2026-09', '2026-10'], }, ]; 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; } function detectKind( c0: string ): 'STORIES' | 'POST' | 'LINKEDIN' | null { const u = c0.toUpperCase(); if (u.startsWith('STORIES') || u === 'STORY' || u.startsWith('STORY ')) return 'STORIES'; if (u.startsWith('LINKEDIN')) return 'LINKEDIN'; if (u.startsWith('POST') || u.startsWith('REEL') || u.startsWith('CARRUSEL')) return 'POST'; // Brava uses "Story" row label if (u === 'STORY' || u.startsWith('HISTORIA')) return 'STORIES'; return null; } function pieceMeta(kind: 'STORIES' | 'POST' | 'LINKEDIN') { if (kind === 'STORIES') { return { platform: 'Instagram', format: 'Stories', pillar: 'stories', kind: 'story', color: '#D9821F', suffix: 'st', }; } if (kind === 'LINKEDIN') { return { platform: 'LinkedIn', format: 'Post', pillar: 'linkedin', kind: 'post', color: '#7C66B8', suffix: 'li', }; } return { platform: 'Instagram', format: 'Post', pillar: 'feed', kind: 'post', color: '#2071cf', suffix: 'post', }; } /** Parse SEMANA × STORIES/POST/LINKEDIN (and Brava Story/POST) grids. */ function parseWeekGrid( prefix: string, rows: SheetRows, year: number, month: number ): RrssPiece[] { const pieces: RrssPiece[] = []; let dayCols: { col: number; day: number }[] = []; let kind: 'STORIES' | 'POST' | 'LINKEDIN' | null = null; 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(); const c0u = c0.toUpperCase(); if ( c0u.startsWith('SEMANA') || /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? '')) ) { dayCols = []; for (let col = 1; col < Math.min(row.length, 12); 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; } // Skip status-only Brava rows if ( /^(PUBLICADO|CLIENTE|APROBADO)$/i.test(c0u) || c0u.startsWith('GUIONES') || c0u.startsWith('CARPETA') ) { continue; } const detected = detectKind(c0u); if (detected) kind = detected; else if (!kind) continue; if (!dayCols.length) continue; const meta = pieceMeta(kind); 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) || /^(PUBLICADO|APROBADO)$/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 = `${prefix}-${date}-${meta.suffix}-${slugify(text).slice(0, 24)}`; pieces.push({ id, date, platform: meta.platform, format: meta.format, pillar: meta.pillar, title: text.split('\n')[0]!.slice(0, 120), body: text, status: 'planned', kind: meta.kind, color: meta.color, }); } } const seen = new Set(); return pieces.filter((p) => { if (seen.has(p.id)) return false; seen.add(p.id); return true; }); } /** Structured BPS "Calendario" tab (fecha | formato | contenido). */ function parseStructuredCalendario( prefix: string, 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: `${prefix}-${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' : 'planned', kind: isStory ? 'story' : 'post', color: isStory ? '#D9821F' : '#2071cf', }; const list = byMonth.get(monthKey) || []; list.push(piece); byMonth.set(monthKey, list); } return byMonth; } function loadSheets( file: string, wanted: Set ): { name: string; rows: SheetRows }[] { const py = ` import openpyxl, json, sys wb=openpyxl.load_workbook(${JSON.stringify(file)}, data_only=True) wanted=set(${JSON.stringify([...wanted])}) out=[] for name in wb.sheetnames: if wanted and name not in wanted and name != 'Calendario': continue ws=wb[name] rows=[] blank=0 for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row or 1, 220), 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}) print(json.dumps(out, ensure_ascii=False, default=str)) `; const raw = execFileSync('python3', ['-c', py], { maxBuffer: 30_000_000 }); return JSON.parse(raw.toString('utf8')); } function monthLabel(ym: string) { const [, m] = ym.split('-').map(Number); const names = [ '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', ]; return `${names[m!]} ${ym.slice(0, 4)}`; } function buildPlan( brand: string, ym: string, pieces: RrssPiece[], sourceFile: string ): RrssMonthPlan { const posts = pieces.filter((p) => p.kind !== 'story').length; const stories = pieces.filter((p) => p.kind === 'story').length; const fileName = sourceFile.split('/').pop() || sourceFile; return { kind: 'rrss_month', month: ym, label: monthLabel(ym), hero: { eyebrow: `4VENTURES × ${brand.toUpperCase()} · PLAN RRSS`, title: `Calendario editorial · ${monthLabel(ym)}`, lede: `Importado desde Drive (${fileName}).`, chips: [ `${posts} posts`, `${stories} stories`, 'Fuente: Google Drive', ], }, pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)), }; } 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 resolveTenantId(slug: string): Promise { const headers = serviceHeaders(); const base = supabaseUrl(); if (!headers || !base) throw new Error('no service role'); const r = await fetch( `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`, { headers } ); if (!r.ok) throw new Error(await r.text()); const rows = (await r.json()) as { id: string }[]; if (!rows[0]?.id) throw new Error(`tenant not found: ${slug}`); return rows[0].id; } async function upsertPlan( tenantId: string, plan: RrssMonthPlan ): Promise { const headers = serviceHeaders(); const base = supabaseUrl(); if (!headers || !base) throw new Error('no service role'); const title = `RRSS ${plan.label}`; const existing = await fetch( `${base}/rest/v1/documents?tenant_id=eq.${tenantId}&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.toLowerCase().includes(plan.label.toLowerCase()); if (month === plan.month || sameTitle) { await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' }, }); } } const ins = await fetch(`${base}/rest/v1/documents`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation', }, body: JSON.stringify({ tenant_id: tenantId, type: 'calendar', title, content: plan, status: 'approved', }), }); 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?.slice(0, 8) ); } async function runJob(job: ClientJob) { console.log(`\n=== ${job.slug} (${job.brand}) ===`); const tenantId = await resolveTenantId(job.slug); const wanted = new Set(Object.keys(job.sheets)); if (job.slug === 'bps') wanted.add('Calendario'); const sheets = loadSheets(job.file, wanted); const byMonth = new Map(); const prefix = job.slug.replace(/[^a-z0-9]+/g, '').slice(0, 12) || 'cal'; for (const sh of sheets) { const ym = job.sheets[sh.name]; if (!ym) continue; if (!job.onlyMonths.includes(ym)) continue; const [year, month] = ym.split('-').map(Number); const pieces = parseWeekGrid( prefix, sh.rows as SheetRows, year!, month! ); byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces)); console.log(` parsed ${sh.name} → ${ym} (${pieces.length})`); } // BPS structured tab fills may–jun 2025 etc. if missing density if (job.slug === 'bps') { const cal = sheets.find((s) => s.name === 'Calendario'); if (cal) { const structured = parseStructuredCalendario( prefix, cal.rows as SheetRows ); for (const [ym, pieces] of structured) { if (!job.onlyMonths.includes(ym)) continue; const cur = byMonth.get(ym) || []; // Prefer structured when grid empty/sparse (<5) if (cur.length < 5) { byMonth.set(ym, mergePieces(pieces, cur)); console.log( ` calendario fill → ${ym} grid=${cur.length} +struct=${pieces.length}` ); } else { const blogs = pieces.filter((p) => p.platform === 'Blog'); if (blogs.length) { byMonth.set(ym, mergePieces(cur, blogs)); } } } } } for (const ym of job.onlyMonths) { const pieces = byMonth.get(ym) || []; if (!pieces.length) { console.log(` SKIP ${ym} (0 pieces in source)`); continue; } await upsertPlan(tenantId, buildPlan(job.brand, ym, pieces, job.file)); } } async function main() { for (const job of JOBS) { await runJob(job); } console.log('\nDone.'); } main().catch((e) => { console.error(e); process.exit(1); });