/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */ import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; export type SheetRows = (string | number | null)[][]; export type ImportResult = { plans: RrssMonthPlan[]; warnings: string[]; sheets: { name: string; pieces: number; mode: string }[]; }; const MONTHS: Record = { enero: 1, february: 2, febrero: 2, marzo: 3, march: 3, abril: 4, april: 4, mayo: 5, may: 5, junio: 6, june: 6, julio: 7, july: 7, agosto: 8, august: 8, septiembre: 9, septimebre: 9, september: 9, octubre: 10, october: 10, noviembre: 11, november: 11, diciembre: 12, december: 12, ene: 1, feb: 2, mar: 3, abr: 4, jun: 6, jul: 7, ago: 8, sep: 9, oct: 10, nov: 11, dic: 12, }; export function slugify(s: string) { return s .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 48); } export 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] || ym} ${y}`; } export function inferYmFromSheetName(name: string, fallbackYear: number): string | null { const raw = name.replace(/\s*\(prov\)\s*/i, '').trim(); const iso = raw.match(/(20\d{2})[-_/ ](\d{1,2})/); if (iso) { const m = Number(iso[2]); if (m >= 1 && m <= 12) return `${iso[1]}-${String(m).padStart(2, '0')}`; } const folded = raw .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, ''); let month: number | null = null; for (const [k, v] of Object.entries(MONTHS)) { if (folded.includes(k)) { month = v; break; } } if (!month) return null; const yMatch = folded.match(/20\d{2}/); let year = yMatch ? Number(yMatch[0]) : fallbackYear; const two = folded.match(/\b(\d{2})\s*$/); if (!yMatch && two) { const n = Number(two[1]); if (n >= 20 && n <= 40) year = 2000 + n; } return `${year}-${String(month).padStart(2, '0')}`; } function cell(v: unknown): string { if (v == null) return ''; if (v instanceof Date && !Number.isNaN(v.getTime())) { return v.toISOString().slice(0, 10); } return String(v).trim(); } function excelSerialToIso(n: number): string | null { if (!Number.isFinite(n) || n < 20000 || n > 80000) return null; const utc = new Date(Date.UTC(1899, 11, 30) + Math.round(n) * 86400000); if (Number.isNaN(utc.getTime())) return null; return utc.toISOString().slice(0, 10); } export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null { if (raw instanceof Date && !Number.isNaN(raw.getTime())) { return raw.toISOString().slice(0, 10); } if (typeof raw === 'number') return excelSerialToIso(raw); const s = cell(raw); if (!s) return null; const iso = s.match(/(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/); if (iso) { return `${iso[1]}-${String(iso[2]).padStart(2, '0')}-${String(iso[3]).padStart(2, '0')}`; } const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](20\d{2})$/); if (dmy) { return `${dmy[3]}-${String(dmy[2]).padStart(2, '0')}-${String(dmy[1]).padStart(2, '0')}`; } const dayOnly = s.match(/^(\d{1,2})$/); if (dayOnly && ymHint) { return `${ymHint}-${String(dayOnly[1]).padStart(2, '0')}`; } return null; } function parseDayHeader(h: string): number | null { const raw = String(h || '').trim(); // Placeholders: x, X 2, Columna N — not calendar days if (!raw || /^x(\s+\d+)?$/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 weekdayHits(row: (string | number | null)[]): number { let n = 0; for (let col = 1; col < Math.min(row.length, 10); col++) { if ( /LUNES|MARTES|MI[EÉ]RCOLES|JUEVES|VIERNES|S[ÁA]BADO|DOMINGO/i.test( cell(row[col]) ) ) { n += 1; } } return n; } function looksLikeGrid(rows: SheetRows): boolean { let hits = 0; for (const row of rows.slice(0, 40)) { const line = (row || []).map(cell).join(' ').toUpperCase(); if ( line.includes('SEMANA') || /\bSTORIES?\b/.test(line) || /^POST\b/.test(line) || /\bSTORY\b/.test(line) ) { hits += 1; } if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1; } return hits >= 2; } function colorFor(platform: string, format: string, kind?: string): string { const f = `${format} ${kind}`.toLowerCase(); if (f.includes('stor')) return '#374151'; if ((platform || '').toLowerCase().includes('linkedin')) return '#7C66B8'; if (f.includes('reel')) return '#7C66B8'; return '#2071cf'; } function normalizePlatform(raw: string, format: string): string { const s = `${raw} ${format}`.toLowerCase(); if (s.includes('linkedin')) return 'linkedin'; if (s.includes('tiktok')) return 'tiktok'; if (s.includes('facebook') || s.includes('fb')) return 'facebook'; if (s.includes('blog')) return 'blog'; return 'instagram'; } function kindFromFormat(format: string): string { const f = format.toLowerCase(); if (f.includes('stor')) return 'story'; if (f.includes('reel')) return 'reel'; return 'post'; } /** * Official editorial grid (Brava Octubre / BPS): * SEMANA N | LUNES d | MARTES d | … | DOMINGO d * Story | …content per day… * POST | …content per day… * Side columns (≥10) are notes/guiones and are ignored. * PUBLICADO / CLIENTE status rows are ignored. */ export function parseWeekGrid( rows: SheetRows, year: number, month: number ): RrssPiece[] { const pieces: RrssPiece[] = []; let dayCols: { col: number; day: number }[] = []; /** Index in dayCols where days wrap into the next month (e.g. 28…1). */ let wrapIdx: number | null = null; let kind: 'STORIES' | 'POST' | null = null; for (const row of rows) { if (!row || !row.some((c) => cell(c))) continue; const c0 = cell(row[0]).toUpperCase(); const isStatus = c0.startsWith('PUBLICADO') || c0.startsWith('CLIENTE') || c0.startsWith('GUION') || c0.startsWith('APROB'); if (isStatus) continue; const isContentRow = c0.startsWith('STORIES') || c0 === 'STORY' || c0 === 'STORIE' || c0.startsWith('POST') || c0.startsWith('REEL') || c0.startsWith('FEED'); const isWeekHeader = !isContentRow && (c0.startsWith('SEMANA') || weekdayHits(row) >= 2 || /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1]))); if (isWeekHeader) { dayCols = []; for (let col = 1; col < Math.min(row.length, 10); col++) { const day = parseDayHeader(cell(row[col])); if (day) dayCols.push({ col, day }); } wrapIdx = null; for (let i = 1; i < dayCols.length; i++) { if (dayCols[i].day < dayCols[i - 1].day && dayCols[i - 1].day >= 20) { wrapIdx = i; break; } } kind = null; continue; } if (c0.startsWith('STORIES') || c0 === 'STORY' || c0 === 'STORIE') { kind = 'STORIES'; } else if (c0.startsWith('POST') || c0.startsWith('REEL') || c0.startsWith('FEED')) { kind = 'POST'; } else if (!kind) { continue; } for (let i = 0; i < dayCols.length; i++) { const { col, day } = dayCols[i]; const text = cell(row[col]); if (!text || /^x(\s+\d+)?$/i.test(text) || /^columna/i.test(text)) continue; let y = year; let m = month; if (wrapIdx != null && i >= wrapIdx) { m = month + 1; if (m > 12) { m = 1; y = year + 1; } } const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const dt = new Date(`${date}T12:00:00Z`); if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue; const format = kind === 'STORIES' ? 'Stories' : /\breel\b/i.test(text) ? 'Reel' : /carrusel|carousel/i.test(text) ? 'Carrusel' : 'Post'; const k = kind === 'STORIES' ? 'story' : kindFromFormat(format); pieces.push({ id: `${date}-${k}-${slugify(text).slice(0, 28)}`, date, platform: 'instagram', format, pillar: k === 'story' ? 'stories' : 'feed', title: text.split('\n')[0].slice(0, 140), hook: text.split('\n')[0].slice(0, 180), body: text, status: 'planned', kind: k, color: colorFor('instagram', format, k), }); } } const seen = new Set(); return pieces.filter((p) => { if (seen.has(p.id)) return false; seen.add(p.id); return true; }); } function headerIndex(headers: string[]): Record { const map: Record = {}; const aliases: Record = { date: ['fecha', 'date', 'dia', 'día', 'day'], platform: ['plataforma', 'platform', 'red', 'canal', 'network'], format: ['formato', 'format', 'tipo', 'type'], title: ['titulo', 'título', 'title', 'pieza', 'nombre'], hook: ['hook', 'gancho'], body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'], cta: ['cta', 'llamada'], hashtags: ['hashtags', 'hashtag', 'tags'], pillar: ['pilar', 'pillar', 'linea', 'línea', 'tema'], time: ['hora', 'time'], objective: ['objetivo', 'objective', 'metric', 'kpi'], status: ['estado', 'status'], }; headers.forEach((h, i) => { const k = h .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim(); for (const [field, list] of Object.entries(aliases)) { if (list.includes(k) && map[field] == null) map[field] = i; } }); return map; } export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] { let headerRow = -1; let idx: Record = {}; for (let i = 0; i < Math.min(rows.length, 12); i++) { const headers = (rows[i] || []).map(cell); const found = headerIndex(headers); if (found.date != null && (found.title != null || found.body != null || found.format != null)) { headerRow = i; idx = found; break; } } if (headerRow < 0) return []; const pieces: RrssPiece[] = []; for (const row of rows.slice(headerRow + 1)) { if (!row) continue; const get = (k: string) => (idx[k] != null ? cell(row[idx[k]]) : ''); const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint); if (!date) continue; const format = get('format') || 'Post'; const title = get('title') || get('body') || get('hook') || format; if (!title.trim()) continue; const platform = normalizePlatform(get('platform'), format); const kind = kindFromFormat(format); const statusRaw = get('status').toLowerCase(); const status = /publi|subido|posted/.test(statusRaw) ? 'published' : /aprob/.test(statusRaw) ? 'approved' : 'planned'; pieces.push({ id: `${date}-${kind}-${slugify(title).slice(0, 28)}`, date, time: get('time') || undefined, platform, format, pillar: get('pillar') || undefined, title: title.split('\n')[0].slice(0, 160), hook: get('hook') || title.split('\n')[0], body: get('body') || title, cta: get('cta') || undefined, hashtags: get('hashtags') || undefined, objective_metric: get('objective') || undefined, status, kind, color: colorFor(platform, format, kind), }); } return pieces; } export function buildPlan( ym: string, pieces: RrssPiece[], tenantLabel: string ): RrssMonthPlan { const sorted = [...pieces].sort((a, b) => a.date.localeCompare(b.date)); const posts = sorted.filter((p) => p.kind !== 'story').length; const stories = sorted.filter((p) => p.kind === 'story').length; const ig = sorted.filter((p) => (p.platform || '').toLowerCase().includes('insta')).length; const li = sorted.filter((p) => (p.platform || '').toLowerCase().includes('linkedin')).length; return { kind: 'rrss_month', month: ym, label: monthLabel(ym), hero: { eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`, title: `Calendario editorial · ${monthLabel(ym)}`, lede: 'Importado desde Excel (rejilla oficial SEMANA / Story / POST) o CSV. Cada pieza queda en su día del mes.', chips: [ `${sorted.length} piezas`, posts ? `${posts} posts` : '', stories ? `${stories} stories` : '', ig ? `${ig} IG` : '', li ? `${li} LinkedIn` : '', ].filter(Boolean), }, pieces: sorted, }; } function asPlan(raw: unknown): RrssMonthPlan | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; if (o.kind === 'rrss_month' && Array.isArray(o.pieces) && typeof o.month === 'string') { return o as unknown as RrssMonthPlan; } return null; } export function parseJsonCalendar(text: string, tenantLabel: string): ImportResult | null { let raw: unknown; try { raw = JSON.parse(text); } catch { return null; } const warnings: string[] = []; const plans: RrssMonthPlan[] = []; const single = asPlan(raw); if (single) { plans.push(single); return { plans, warnings, sheets: [{ name: 'json', pieces: single.pieces.length, mode: 'json' }], }; } if (Array.isArray(raw)) { if (raw.length && asPlan(raw[0])) { for (const item of raw) { const p = asPlan(item); if (p) plans.push(p); } return { plans, warnings, sheets: [ { name: 'json', pieces: plans.reduce((a, p) => a + p.pieces.length, 0), mode: 'json', }, ], }; } const pieces = raw as RrssPiece[]; if (pieces[0] && typeof pieces[0] === 'object' && 'date' in pieces[0]) { const by = new Map(); for (const p of pieces) { const d = String(p.date || '').slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) continue; const ym = d.slice(0, 7); const list = by.get(ym) || []; list.push(p); by.set(ym, list); } for (const [ym, list] of by) plans.push(buildPlan(ym, list, tenantLabel)); return { plans, warnings, sheets: [{ name: 'json-pieces', pieces: pieces.length, mode: 'json' }], }; } } return { plans, warnings: ['JSON no reconocido como calendario RRSS'], sheets: [] }; } export function sheetsToPlans( sheets: { name: string; rows: SheetRows }[], tenantLabel: string, fallbackYear = new Date().getFullYear() ): ImportResult { const warnings: string[] = []; const byMonth = new Map(); const report: ImportResult['sheets'] = []; for (const sh of sheets) { if (!sh.rows?.length) continue; const ym = inferYmFromSheetName(sh.name, fallbackYear); let mode = 'empty'; let pieces: RrssPiece[] = []; if (looksLikeGrid(sh.rows) && ym) { const [y, m] = ym.split('-').map(Number); pieces = parseWeekGrid(sh.rows, y, m); mode = 'grid'; } if (!pieces.length) { const tabular = parseTabular(sh.rows, ym || undefined); if (tabular.length) { pieces = tabular; mode = 'tabla'; } } if (!pieces.length && looksLikeGrid(sh.rows) && !ym) { warnings.push( `Hoja «${sh.name}»: parece calendario semanal pero no se pudo leer el mes. Renómbrala (ej. Octubre 2026).` ); } report.push({ name: sh.name, pieces: pieces.length, mode }); for (const p of pieces) { const key = p.date.slice(0, 7); const list = byMonth.get(key) || []; list.push(p); byMonth.set(key, list); } } const plans = [...byMonth.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([ym, pieces]) => { const seen = new Set(); const uniq = pieces.filter((p) => { if (seen.has(p.id)) return false; seen.add(p.id); return true; }); return buildPlan(ym, uniq, tenantLabel); }); if (!plans.length) { warnings.push( 'No se encontraron piezas. Usa columnas fecha + título (o copy), o una rejilla SEMANA / STORIES / POST como Blue Parrot.' ); } return { plans, warnings, sheets: report }; } /** Fallback tabular CSV. Preferred official format = Excel week grid like Brava Octubre. */ export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo 2026-10-01,instagram,Stories,H1 · ENCUESTA Otoño: ¿qué le pides a tu piel?,Encuesta otoño,Sticker encuesta,Responde a la story,,stories,09:00,Respuestas 2026-10-03,instagram,Reel,15_REEL TREND · Cosas que nunca escucharás en Brava,Trend propio,Copy del reel,Guarda este post,#Brava,feed,10:30,Guardados 2026-10-05,instagram,Carrusel,POST 1 · ¿Lista para un peeling?,Señales sí / aún no,Copy del carrusel,Pedir cita,,feed,10:30,Citas `; /** Minimal October-style grid as TSV (for docs / paste into Excel). */ export const SAMPLE_GRID_TSV = `SEMANA 1\tx\tx\tx\tJUEVES 1\tVIERNES 2\tSÁBADO 3\tDOMINGO 4 Story\t\t\t\tH1 · ENCUESTA\\nOtoño: ¿qué le pides a tu piel?\tH2 · V/F\\n¿Octubre ya es mes de peeling?\t\t POST\t\t\t\t\t\t15_REEL TREND · Ejemplo reel\t SEMANA 2\tLUNES 5\tMARTES 6\tMIERCOLES 7\tJUEVES 8\tVIERNES 9\tSÁBADO 10\tDOMINGO 11 Story\tH4 · ENCUESTA\t\tH5 · CAJÓN\t\tH6 · V/F\tH7 · BTS\t POST\tPOST 1 · Título del carrusel · PLANT 4\t\tReel TREND: ejemplo\tPOST 2 · Debate · PLANT 2\t\t\t `;