import { analyze, type Analysis } from '../../editor/analysis/analyzer'; import { failingTests, type FailingTests, } from '../../editor/lib/failing-tests'; import { leadKeyword, parseKeywords } from '../../editor/lib/keywords'; import type { ISeoBulkChange, ISeoBulkFields, ISeoBulkList, ISeoBulkRow, } from '../../service/seo/seo.interface'; import type { ISeoPostFixRequest } from '../../service/seo/seo.service'; import { previewTemplate } from './template-preview'; /** * The bulk editor's rows: what each prints with the fields as edited, the * editor's analysis of it, what a save sends, and how a draft of Fix with * AI goes into the fields. */ /** What one draft costs: the snippet of Fix with AI, as the agent bills it. */ export const DRAFT_CREDITS = 10; /** What the agent proposes for a row; empty where it proposes nothing. */ export interface BulkProposal { title: string; description: string; keyword: string; } /** Where a row's draft stands. */ export interface BulkDraft { status: 'queued' | 'running' | 'done' | 'failed' | 'skipped'; proposal?: BulkProposal; error?: string; credits?: number; } /** A row with the fields as they stand now, and what follows from them. */ export interface BulkRowState { title: string; description: string; analysis: Analysis; /** The score kept on save: none without a focus keyword. */ score: number | null; tests: FailingTests; } /** * @param count - Drafts asked for. * @return What they cost. */ export const draftCost = (count: number): number => count * DRAFT_CREDITS; /** * The title and description a row prints with these fields: an empty field * prints its template, and the variables print this post's values. Unedited, * they are what the server printed. * * @param row - The row as loaded. * @param fields - The fields as edited. * @param list - The page, for the separator and the site's variables. * @return The two texts. */ export const renderRow = ( row: ISeoBulkRow, fields: ISeoBulkFields, list: Pick ): { title: string; description: string } => { const values = Object.entries({ ...list.variables, ...row.variables, '%focuskw%': parseKeywords(fields.keywords)[0] ?? '', }).map(([name, example]) => ({ name, label: '', example })); const render = (own: string, template: string): string => previewTemplate(own.trim() !== '' ? own : template, values, list.separator); return { title: fields.title === row.fields.title && fields.keywords === row.fields.keywords ? row.input.title : render(fields.title, row.templates.title), description: fields.description === row.fields.description && fields.keywords === row.fields.keywords ? row.input.description : render(fields.description, row.templates.description), }; }; /** * A row with the fields as edited: what it prints, the editor's analysis * of it and what is left to do. * * @param row - The row as loaded. * @param fields - The fields as edited. * @param list - The page. * @param siteHost - This site's host, to tell internal links apart. * @return The state. */ export const rowState = ( row: ISeoBulkRow, fields: ISeoBulkFields, list: Pick, siteHost: string ): BulkRowState => { const rendered = renderRow(row, fields, list); const keywords = parseKeywords(fields.keywords); const analysis = analyze({ ...row.input, title: rendered.title, description: rendered.description, keyword: keywords[0] ?? '', keywords, siteHost, scope: 'post', }); return { ...rendered, analysis, score: keywords.length > 0 ? analysis.score : null, tests: failingTests(analysis), }; }; /** * What a save sends for a row: the fields that changed, and the score they * make; nothing when no field changed. * * @param row - The row as loaded. * @param fields - The fields as edited. * @param score - The score with them. * @return The change, or null. */ export const changeOf = ( row: ISeoBulkRow, fields: ISeoBulkFields, score: number | null ): ISeoBulkChange | null => { const changed = (Object.keys(fields) as (keyof ISeoBulkFields)[]).filter( key => fields[key] !== row.fields[key] ); if (changed.length === 0) { return null; } return { id: row.id, ...Object.fromEntries(changed.map(key => [key, fields[key]])), ...(score === null ? {} : { score }), }; }; /** * A draft over the fields: the title and description it wrote, and its * keyword leading the list, the old first one out and the secondary ones * kept, as Fix with AI applies it in the editor. What it left empty keeps * what the row has. * * @param fields - The fields as edited. * @param proposal - The draft. * @return The fields with the draft in. */ export const mergeDraft = ( fields: ISeoBulkFields, proposal: BulkProposal ): ISeoBulkFields => ({ title: proposal.title.trim() || fields.title, description: proposal.description.trim() || fields.description, keywords: leadKeyword(fields.keywords, proposal.keyword), }); /** * What Fix with AI is given of a row: the page as stored, its keyword as * edited and the failing tests, for the snippet alone. * * @param row - The row. * @param fields - The fields as edited. * @param tests - What the analysis left to do. * @return The request. */ export const draftRequest = ( row: ISeoBulkRow, fields: ISeoBulkFields, tests: FailingTests ): ISeoPostFixRequest => ({ post_id: row.id, title: row.post_title, content: row.input.content, excerpt: row.excerpt, keyword: parseKeywords(fields.keywords)[0] ?? '', slug: row.input.slug, tests: tests.fixLines, test_ids: tests.fixIds, snippet: true, rewrite: false, keyword_used_elsewhere: false, }); /** * @param row - The row. * @return Whether Fix with AI already spent credits on it today. */ export const ranToday = (row: ISeoBulkRow): boolean => row.ran_today.includes('fix') || row.ran_today.includes('rewrite');