From 04ea6de08a3ea10399d1b253f060f3a036a56fd1 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 17:20:31 +0200 Subject: [PATCH] fix: notas Seguimiento Semanal con HTML completo, enlaces y formato Exporta el Google Doc en HTML, parte por fecha y guarda todo el bloque (listas, negritas, links) sin truncar. Co-authored-by: Cursor --- src/app/api/admin/superhuman/sync/route.ts | 2 +- src/components/SeguimientoBoard.tsx | 27 +- src/lib/superhuman-docs.ts | 271 ++++++++++++++++++--- 3 files changed, 266 insertions(+), 34 deletions(-) diff --git a/src/app/api/admin/superhuman/sync/route.ts b/src/app/api/admin/superhuman/sync/route.ts index 630d69f..ca65a76 100644 --- a/src/app/api/admin/superhuman/sync/route.ts +++ b/src/app/api/admin/superhuman/sync/route.ts @@ -18,7 +18,7 @@ import { } from '@/lib/superhuman-docs'; export const dynamic = 'force-dynamic'; -export const maxDuration = 120; +export const maxDuration = 300; async function requireAgency() { const supabase = await createClient(); diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx index f56346a..39b6d38 100644 --- a/src/components/SeguimientoBoard.tsx +++ b/src/components/SeguimientoBoard.tsx @@ -48,6 +48,18 @@ function typeTone(t: string) { return 'bg-violet-50 text-violet-800 border-violet-200'; } +function looksLikeHtml(s: string) { + return /<\/?(?:ul|ol|li|p|a|strong|br)\b/i.test(s); +} + +function escapeHtml(s: string) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + export default function SeguimientoBoard() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -319,9 +331,18 @@ export default function SeguimientoBoard() { )} {open && e.body_md && ( -
-                        {e.body_md}
-                      
+
{ + const t = ev.target as HTMLElement; + if (t.closest('a')) ev.stopPropagation(); + }} + dangerouslySetInnerHTML={{ + __html: looksLikeHtml(e.body_md) + ? e.body_md + : `
${escapeHtml(e.body_md)}
`, + }} + /> )} diff --git a/src/lib/superhuman-docs.ts b/src/lib/superhuman-docs.ts index 09c34b9..c2666df 100644 --- a/src/lib/superhuman-docs.ts +++ b/src/lib/superhuman-docs.ts @@ -167,7 +167,21 @@ export function parseMetricsFromMarkdown(md: string): ParsedMetrics { return metrics; } -/** Split plain-text / markdown seguimiento docs into one section per day heading. */ +function parseDayLabel(raw: string): { dateIso: string; label: string } | null { + const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/); + if (!m) return null; + let y = Number(m[3]); + const mo = Number(m[2]); + const d = Number(m[1]); + if (y < 100) y += 2000; + if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null; + return { + dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, + label: `${d}/${mo}/${String(y).slice(2)}`, + }; +} + +/** Split plain-text seguimiento docs into one section per day heading. */ export function splitNotesByDay(text: string): DayNoteSection[] { const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); const dayRe = @@ -178,17 +192,9 @@ export function splitNotesByDay(text: string): DayNoteSection[] { for (let i = 0; i < lines.length; i++) { const m = lines[i].trim().match(dayRe); if (!m) continue; - let y = Number(m[3]); - const mo = Number(m[2]); - const d = Number(m[1]); - if (y < 100) y += 2000; - if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) continue; - const dateIso = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`; - hits.push({ - idx: i, - label: `${d}/${mo}/${String(y).slice(2)}`, - dateIso, - }); + const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`); + if (!parsed) continue; + hits.push({ idx: i, ...parsed }); } if (!hits.length) return []; @@ -211,7 +217,151 @@ export function splitNotesByDay(text: string): DayNoteSection[] { }); } - // Newest → oldest + sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso)); + return sections; +} + +function unwrapGoogleRedirect(href: string): string { + try { + const u = new URL(href); + if ( + u.hostname.includes('google.') && + (u.pathname.includes('/url') || u.searchParams.has('q')) + ) { + const q = u.searchParams.get('q') || u.searchParams.get('url'); + if (q) return q; + } + } catch { + /* keep original */ + } + return href; +} + +/** Clean Google Docs HTML fragment: keep structure, bold, lists, real links. */ +export function sanitizeSeguimientoHtml(raw: string): string { + let html = raw + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, ''); + + // Bold spans from Docs + html = html.replace( + /]*font-weight:\s*700[^>]*>([\s\S]*?)<\/span>/gi, + '$1' + ); + html = html.replace( + /]*font-weight:\s*bold[^>]*>([\s\S]*?)<\/span>/gi, + '$1' + ); + + // Normalize links + unwrap google redirects + html = html.replace( + /]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, + (_m, href: string, inner: string) => { + const cleanHref = unwrapGoogleRedirect(String(href).replace(/&/g, '&')) + .replace(/"/g, '') + .trim(); + if (!/^https?:\/\//i.test(cleanHref) && !cleanHref.startsWith('mailto:')) { + return inner; + } + const text = inner.replace(/<[^>]+>/g, '').trim() || cleanHref; + return `${text}`; + } + ); + + // Drop remaining spans but keep text + html = html.replace(/<\/?span\b[^>]*>/gi, ''); + + // Strip attributes except href on anchors; keep semantic tags + html = html.replace( + /<\/?(ul|ol|li|p|br|strong|b|em|i|u|h[1-4])\b[^>]*>/gi, + (tag) => { + const name = tag.match(/<\/?([a-z0-9]+)/i)?.[1]?.toLowerCase(); + if (!name) return ''; + if (tag.startsWith('`; + if (name === 'br') return '
'; + return `<${name}>`; + } + ); + + // Remove any leftover tags that are not allowlisted + html = html.replace( + /<\/?(?!\/?(?:ul|ol|li|p|br|strong|b|em|i|u|h[1-4]|a)\b)[a-z][^>]*>/gi, + '' + ); + + // Collapse empty paragraphs / excess whitespace + html = html + .replace(/

\s*<\/p>/gi, '') + .replace(/(?:\s*){3,}/gi, '

') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return html; +} + +function htmlToPlainPreview(html: string): string { + return html + .replace(//gi, '\n') + .replace(/<\/(p|li|h[1-4]|div)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * Split Google Docs HTML export by day headings (bold date-only paragraphs). + * Body of each note = todo el contenido hasta la fecha siguiente (HTML limpio). + */ +export function splitHtmlNotesByDay(htmlDoc: string): DayNoteSection[] { + const bodyMatch = htmlDoc.match(/]*>([\s\S]*)<\/body>/i); + const body = bodyMatch ? bodyMatch[1] : htmlDoc; + + // Match every

…

; keep those whose visible text is exactly a date + const pRe = /]*>[\s\S]*?<\/p>/gi; + const hits: { start: number; end: number; dateIso: string; label: string }[] = + []; + let m: RegExpExecArray | null; + while ((m = pRe.exec(body)) !== null) { + const inner = m[0].replace(/^]*>/i, '').replace(/<\/p>$/i, ''); + const text = inner + .replace(//gi, ' ') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + const parsed = parseDayLabel(text); + if (!parsed) continue; + hits.push({ + start: m.index, + end: m.index + m[0].length, + dateIso: parsed.dateIso, + label: parsed.label, + }); + } + + if (!hits.length) return []; + + const sections: DayNoteSection[] = []; + for (let i = 0; i < hits.length; i++) { + const contentStart = hits[i].end; + const contentEnd = i + 1 < hits.length ? hits[i + 1].start : body.length; + const fragment = body.slice(contentStart, contentEnd).trim(); + if (!fragment) continue; + const cleaned = sanitizeSeguimientoHtml(fragment); + if (!cleaned || htmlToPlainPreview(cleaned).length < 2) continue; + sections.push({ + dateIso: hits[i].dateIso, + label: hits[i].label, + body: cleaned, + }); + } + sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso)); return sections; } @@ -393,7 +543,7 @@ type DriveDocHit = { webViewLink?: string | null; }; -/** Find Google Doc «Seguimiento …» matching page/tenant name. */ +/** Prefer docs literally named «Seguimiento Semanal …». */ export async function findSeguimientoGoogleDoc( hints: string[] ): Promise { @@ -402,6 +552,7 @@ export async function findSeguimientoGoogleDoc( .map((h) => h.replace(/^\s*\d+\.\s*/, '').trim()) .filter(Boolean); + // Exact name first (incl. «Seguimiento Semanal X») for (const name of cleaned) { const safe = name.replace(/'/g, "\\'"); const params = new URLSearchParams({ @@ -422,9 +573,9 @@ export async function findSeguimientoGoogleDoc( } const params = new URLSearchParams({ - q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false", + q: "name contains 'Seguimiento Semanal' and mimeType='application/vnd.google-apps.document' and trashed=false", fields: 'files(id,name,webViewLink,modifiedTime)', - pageSize: '40', + pageSize: '50', corpora: 'allDrives', includeItemsFromAllDrives: 'true', supportsAllDrives: 'true', @@ -433,12 +584,36 @@ export async function findSeguimientoGoogleDoc( headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', }); - if (!r.ok) return null; + if (!r.ok) { + // fallback broader + const p2 = new URLSearchParams({ + q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false", + fields: 'files(id,name,webViewLink,modifiedTime)', + pageSize: '50', + corpora: 'allDrives', + includeItemsFromAllDrives: 'true', + supportsAllDrives: 'true', + }); + const r2 = await fetch(`https://www.googleapis.com/drive/v3/files?${p2}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + if (!r2.ok) return null; + const files2 = ((await r2.json()) as { files?: DriveDocHit[] }).files || []; + return scoreDriveHits(files2, cleaned); + } const files = ((await r.json()) as { files?: DriveDocHit[] }).files || []; + return scoreDriveHits(files, cleaned); +} + +function scoreDriveHits( + files: DriveDocHit[], + cleaned: string[] +): DriveDocHit | null { let best: { file: DriveDocHit; score: number } | null = null; for (const f of files) { const fn = normalizeName(f.name || ''); - let score = 0; + let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0; for (const h of cleaned) { const hn = normalizeName(h); if (!hn) continue; @@ -447,7 +622,10 @@ export async function findSeguimientoGoogleDoc( else { const tokens = hn .split(' ') - .filter((t) => t.length > 3 && t !== 'seguimiento' && t !== 'semanal'); + .filter( + (t) => + t.length > 3 && t !== 'seguimiento' && t !== 'semanal' + ); const hits = tokens.filter((t) => fn.includes(t)).length; if (tokens.length && hits / tokens.length >= 0.6) { score = Math.max(score, 70 + hits * 5); @@ -459,6 +637,22 @@ export async function findSeguimientoGoogleDoc( return best && best.score >= 70 ? best.file : null; } +export async function exportGoogleDocHtml(fileId: string): Promise { + const token = await getValidAccessToken(); + const r = await fetch( + `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=text%2Fhtml`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + } + ); + if (!r.ok) { + throw new Error(`Drive export HTML ${r.status}: ${(await r.text()).slice(0, 200)}`); + } + return await r.text(); +} + +/** @deprecated prefer exportGoogleDocHtml — plain loses links/format */ export async function exportGoogleDocPlain(fileId: string): Promise { const token = await getValidAccessToken(); const r = await fetch( @@ -474,17 +668,33 @@ export async function exportGoogleDocPlain(fileId: string): Promise { return await r.text(); } -function dayRowsFromText(opts: { +function dayRowsFromHtml(opts: { tenantId: string; - text: string; + html: string; fileId: string; browserLink: string | null; maxEntries: number; now: string; }): SeguimientoSyncRow[] { - const sections = splitNotesByDay(opts.text).slice(0, opts.maxEntries); + let sections = splitHtmlNotesByDay(opts.html); + // Fallback: plain text split if HTML date markers not found + if (!sections.length) { + const plain = htmlToPlainPreview( + opts.html.replace(//gi, '') + ); + sections = splitNotesByDay(plain).map((s) => ({ + ...s, + body: `
${s.body
+        .replace(/&/g, '&')
+        .replace(//g, '>')}
`, + })); + } + sections = sections.slice(0, opts.maxEntries); + return sections.map((sec) => { - const metrics = parseMetricsFromMarkdown(sec.body); + const plain = htmlToPlainPreview(sec.body); + const metrics = parseMetricsFromMarkdown(plain); const summaryBits = [ metrics.periodo ? `Periodo: ${metrics.periodo}` : null, metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null, @@ -493,7 +703,7 @@ function dayRowsFromText(opts: { metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null, ].filter(Boolean); const firstLine = - sec.body + plain .split('\n') .map((l) => l.replace(/^[\s*•\-]+/, '').trim()) .find((l) => l.length > 8) || ''; @@ -507,7 +717,8 @@ function dayRowsFromText(opts: { summaryBits.length > 0 ? summaryBits.join(' · ') : firstLine.slice(0, 220) || null, - body_md: excerpt(sec.body, 6000), + // Full day block (no truncate): HTML con listas, negritas y enlaces + body_md: sec.body, browser_link: opts.browserLink, metrics, occurred_at: `${sec.dateIso}T12:00:00.000Z`, @@ -531,7 +742,7 @@ export async function buildSeguimientoRowsForDoc(opts: { const now = new Date().toISOString(); const rows: SeguimientoSyncRow[] = []; - // 1) Google Doc embebido / emparejado por nombre (fuente real de notas por día) + // 1) Google Doc «Seguimiento Semanal …» — HTML completo partido por fecha const nameHints = [ ...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name), opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '', @@ -542,11 +753,11 @@ export async function buildSeguimientoRowsForDoc(opts: { try { const hit = await findSeguimientoGoogleDoc(nameHints); if (hit) { - const text = await exportGoogleDocPlain(hit.id); + const html = await exportGoogleDocHtml(hit.id); rows.push( - ...dayRowsFromText({ + ...dayRowsFromHtml({ tenantId: opts.tenantId, - text, + html, fileId: hit.id, browserLink: hit.webViewLink || @@ -625,7 +836,7 @@ export async function upsertSeguimientoRows( if (!rows.length) return 0; const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); - const chunk = 40; + const chunk = 15; for (let i = 0; i < rows.length; i += chunk) { const part = rows.slice(i, i + chunk); const r = await fetch(