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 <cursoragent@cursor.com>
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@/lib/superhuman-docs';
|
} from '@/lib/superhuman-docs';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 300;
|
||||||
|
|
||||||
async function requireAgency() {
|
async function requireAgency() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ function typeTone(t: string) {
|
|||||||
return 'bg-violet-50 text-violet-800 border-violet-200';
|
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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
export default function SeguimientoBoard() {
|
export default function SeguimientoBoard() {
|
||||||
const [data, setData] = useState<Payload | null>(null);
|
const [data, setData] = useState<Payload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -319,9 +331,18 @@ export default function SeguimientoBoard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{open && e.body_md && (
|
{open && e.body_md && (
|
||||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-700 bg-gray-50 border border-gray-100 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
|
<div
|
||||||
{e.body_md}
|
className="mt-3 text-sm text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-4 max-h-[36rem] overflow-y-auto [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-brand-600 [&_a]:underline [&_strong]:font-semibold [&_p]:my-1.5"
|
||||||
</pre>
|
onClick={(ev) => {
|
||||||
|
const t = ev.target as HTMLElement;
|
||||||
|
if (t.closest('a')) ev.stopPropagation();
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: looksLikeHtml(e.body_md)
|
||||||
|
? e.body_md
|
||||||
|
: `<pre class="whitespace-pre-wrap font-sans m-0 text-xs">${escapeHtml(e.body_md)}</pre>`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -167,7 +167,21 @@ export function parseMetricsFromMarkdown(md: string): ParsedMetrics {
|
|||||||
return metrics;
|
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[] {
|
export function splitNotesByDay(text: string): DayNoteSection[] {
|
||||||
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||||
const dayRe =
|
const dayRe =
|
||||||
@@ -178,17 +192,9 @@ export function splitNotesByDay(text: string): DayNoteSection[] {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const m = lines[i].trim().match(dayRe);
|
const m = lines[i].trim().match(dayRe);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
let y = Number(m[3]);
|
const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`);
|
||||||
const mo = Number(m[2]);
|
if (!parsed) continue;
|
||||||
const d = Number(m[1]);
|
hits.push({ idx: i, ...parsed });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hits.length) return [];
|
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(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
|
// Bold spans from Docs
|
||||||
|
html = html.replace(
|
||||||
|
/<span[^>]*font-weight:\s*700[^>]*>([\s\S]*?)<\/span>/gi,
|
||||||
|
'<strong>$1</strong>'
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<span[^>]*font-weight:\s*bold[^>]*>([\s\S]*?)<\/span>/gi,
|
||||||
|
'<strong>$1</strong>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normalize links + unwrap google redirects
|
||||||
|
html = html.replace(
|
||||||
|
/<a\b[^>]*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 `<a href="${cleanHref}" target="_blank" rel="noreferrer noopener">${text}</a>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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('</')) return `</${name}>`;
|
||||||
|
if (name === 'br') return '<br/>';
|
||||||
|
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(/<p>\s*<\/p>/gi, '')
|
||||||
|
.replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br/><br/>')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainPreview(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/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(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||||
|
const body = bodyMatch ? bodyMatch[1] : htmlDoc;
|
||||||
|
|
||||||
|
// Match every <p>…</p>; keep those whose visible text is exactly a date
|
||||||
|
const pRe = /<p\b[^>]*>[\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(/^<p\b[^>]*>/i, '').replace(/<\/p>$/i, '');
|
||||||
|
const text = inner
|
||||||
|
.replace(/<br\s*\/?>/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));
|
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
@@ -393,7 +543,7 @@ type DriveDocHit = {
|
|||||||
webViewLink?: string | null;
|
webViewLink?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Find Google Doc «Seguimiento …» matching page/tenant name. */
|
/** Prefer docs literally named «Seguimiento Semanal …». */
|
||||||
export async function findSeguimientoGoogleDoc(
|
export async function findSeguimientoGoogleDoc(
|
||||||
hints: string[]
|
hints: string[]
|
||||||
): Promise<DriveDocHit | null> {
|
): Promise<DriveDocHit | null> {
|
||||||
@@ -402,6 +552,7 @@ export async function findSeguimientoGoogleDoc(
|
|||||||
.map((h) => h.replace(/^\s*\d+\.\s*/, '').trim())
|
.map((h) => h.replace(/^\s*\d+\.\s*/, '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Exact name first (incl. «Seguimiento Semanal X»)
|
||||||
for (const name of cleaned) {
|
for (const name of cleaned) {
|
||||||
const safe = name.replace(/'/g, "\\'");
|
const safe = name.replace(/'/g, "\\'");
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -422,9 +573,9 @@ export async function findSeguimientoGoogleDoc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
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)',
|
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||||
pageSize: '40',
|
pageSize: '50',
|
||||||
corpora: 'allDrives',
|
corpora: 'allDrives',
|
||||||
includeItemsFromAllDrives: 'true',
|
includeItemsFromAllDrives: 'true',
|
||||||
supportsAllDrives: 'true',
|
supportsAllDrives: 'true',
|
||||||
@@ -433,12 +584,36 @@ export async function findSeguimientoGoogleDoc(
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
cache: 'no-store',
|
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 || [];
|
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;
|
let best: { file: DriveDocHit; score: number } | null = null;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const fn = normalizeName(f.name || '');
|
const fn = normalizeName(f.name || '');
|
||||||
let score = 0;
|
let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0;
|
||||||
for (const h of cleaned) {
|
for (const h of cleaned) {
|
||||||
const hn = normalizeName(h);
|
const hn = normalizeName(h);
|
||||||
if (!hn) continue;
|
if (!hn) continue;
|
||||||
@@ -447,7 +622,10 @@ export async function findSeguimientoGoogleDoc(
|
|||||||
else {
|
else {
|
||||||
const tokens = hn
|
const tokens = hn
|
||||||
.split(' ')
|
.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;
|
const hits = tokens.filter((t) => fn.includes(t)).length;
|
||||||
if (tokens.length && hits / tokens.length >= 0.6) {
|
if (tokens.length && hits / tokens.length >= 0.6) {
|
||||||
score = Math.max(score, 70 + hits * 5);
|
score = Math.max(score, 70 + hits * 5);
|
||||||
@@ -459,6 +637,22 @@ export async function findSeguimientoGoogleDoc(
|
|||||||
return best && best.score >= 70 ? best.file : null;
|
return best && best.score >= 70 ? best.file : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportGoogleDocHtml(fileId: string): Promise<string> {
|
||||||
|
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<string> {
|
export async function exportGoogleDocPlain(fileId: string): Promise<string> {
|
||||||
const token = await getValidAccessToken();
|
const token = await getValidAccessToken();
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
@@ -474,17 +668,33 @@ export async function exportGoogleDocPlain(fileId: string): Promise<string> {
|
|||||||
return await r.text();
|
return await r.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayRowsFromText(opts: {
|
function dayRowsFromHtml(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
text: string;
|
html: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
browserLink: string | null;
|
browserLink: string | null;
|
||||||
maxEntries: number;
|
maxEntries: number;
|
||||||
now: string;
|
now: string;
|
||||||
}): SeguimientoSyncRow[] {
|
}): 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(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
);
|
||||||
|
sections = splitNotesByDay(plain).map((s) => ({
|
||||||
|
...s,
|
||||||
|
body: `<pre>${s.body
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')}</pre>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
sections = sections.slice(0, opts.maxEntries);
|
||||||
|
|
||||||
return sections.map((sec) => {
|
return sections.map((sec) => {
|
||||||
const metrics = parseMetricsFromMarkdown(sec.body);
|
const plain = htmlToPlainPreview(sec.body);
|
||||||
|
const metrics = parseMetricsFromMarkdown(plain);
|
||||||
const summaryBits = [
|
const summaryBits = [
|
||||||
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
||||||
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
||||||
@@ -493,7 +703,7 @@ function dayRowsFromText(opts: {
|
|||||||
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const firstLine =
|
const firstLine =
|
||||||
sec.body
|
plain
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||||
.find((l) => l.length > 8) || '';
|
.find((l) => l.length > 8) || '';
|
||||||
@@ -507,7 +717,8 @@ function dayRowsFromText(opts: {
|
|||||||
summaryBits.length > 0
|
summaryBits.length > 0
|
||||||
? summaryBits.join(' · ')
|
? summaryBits.join(' · ')
|
||||||
: firstLine.slice(0, 220) || null,
|
: 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,
|
browser_link: opts.browserLink,
|
||||||
metrics,
|
metrics,
|
||||||
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||||
@@ -531,7 +742,7 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const rows: SeguimientoSyncRow[] = [];
|
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 = [
|
const nameHints = [
|
||||||
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||||
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||||
@@ -542,11 +753,11 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
|||||||
try {
|
try {
|
||||||
const hit = await findSeguimientoGoogleDoc(nameHints);
|
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const text = await exportGoogleDocPlain(hit.id);
|
const html = await exportGoogleDocHtml(hit.id);
|
||||||
rows.push(
|
rows.push(
|
||||||
...dayRowsFromText({
|
...dayRowsFromHtml({
|
||||||
tenantId: opts.tenantId,
|
tenantId: opts.tenantId,
|
||||||
text,
|
html,
|
||||||
fileId: hit.id,
|
fileId: hit.id,
|
||||||
browserLink:
|
browserLink:
|
||||||
hit.webViewLink ||
|
hit.webViewLink ||
|
||||||
@@ -625,7 +836,7 @@ export async function upsertSeguimientoRows(
|
|||||||
if (!rows.length) return 0;
|
if (!rows.length) return 0;
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (!headers) throw new Error('no service role');
|
if (!headers) throw new Error('no service role');
|
||||||
const chunk = 40;
|
const chunk = 15;
|
||||||
for (let i = 0; i < rows.length; i += chunk) {
|
for (let i = 0; i < rows.length; i += chunk) {
|
||||||
const part = rows.slice(i, i + chunk);
|
const part = rows.slice(i, i + chunk);
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
|
|||||||
Reference in New Issue
Block a user