Feature/ai post suggestions (#40)

* feat: first cut on ai suggestion system for title and summary

* feat: completion of titling/excerpt/slug-suggestion AI quick action

* feat: feeds use existing excerpts. also documentation.

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-07 09:54:13 +01:00
committed by GitHub
parent 72b21ddba7
commit 9871cb827f
30 changed files with 1270 additions and 245 deletions

View File

@@ -156,6 +156,13 @@ function excerptToXhtml(post: PostData): string {
return paragraphToXhtml(firstParagraph);
}
function feedContentToXhtml(post: PostData): string {
if (typeof post.excerpt === 'string' && post.excerpt.trim().length > 0) {
return paragraphToXhtml(post.excerpt.trim());
}
return markdownToXhtml(post.content || '');
}
function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>');
}
@@ -384,7 +391,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const contentXhtml = feedContentToXhtml(post);
const categories = [
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</category>`),
@@ -425,7 +432,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || '');
const contentXhtml = feedContentToXhtml(post);
const categories = [
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),

View File

@@ -448,6 +448,7 @@ export class PostEngine extends EventEmitter {
// automatically transition to draft status (content moves from file to DB)
const isContentOrMetadataChange = data.content !== undefined ||
data.title !== undefined ||
data.slug !== undefined ||
data.tags !== undefined ||
data.categories !== undefined ||
data.excerpt !== undefined ||
@@ -459,12 +460,32 @@ export class PostEngine extends EventEmitter {
newStatus = 'draft';
}
// Auto-update slug when title changes, but only if post was never published
let newSlug = data.slug ?? existing.slug;
if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) {
// Explicit slug changes are only allowed before the first publish.
const requestedSlug = typeof data.slug === 'string' ? slugify(data.slug) : undefined;
let newSlug = existing.slug;
if (!existing.publishedAt && requestedSlug) {
newSlug = await this.isSlugAvailable(requestedSlug, id)
? requestedSlug
: await this.generateUniqueSlug(requestedSlug, id);
} else if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) {
newSlug = await this.generateUniqueSlug(data.title || 'untitled', id);
}
// If slug changed and the post has a file on disk, rename the file
let newFilePath: string | undefined;
if (newSlug !== existing.slug) {
const dbRow = await db.select().from(posts).where(eq(posts.id, id)).get();
if (dbRow?.filePath) {
const dir = path.dirname(dbRow.filePath);
newFilePath = path.join(dir, `${newSlug}.md`);
try {
await fs.rename(dbRow.filePath, newFilePath);
} catch {
// Old file may not exist
}
}
}
const updated: PostData = {
...existing,
...data,
@@ -478,21 +499,25 @@ export class PostEngine extends EventEmitter {
const checksum = this.calculateChecksum(updated.content);
// All updates go to DB only — no file writes
const dbSet: Record<string, unknown> = {
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
content: updated.content,
status: updated.status,
author: updated.author,
updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt,
checksum,
tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories),
language: updated.language || null,
};
if (newFilePath !== undefined) {
dbSet.filePath = newFilePath;
}
await db.update(posts)
.set({
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
content: updated.content,
status: updated.status,
author: updated.author,
updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt,
checksum,
tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories),
language: updated.language || null,
})
.set(dbSet)
.where(eq(posts.id, id));
// Update FTS index

View File

@@ -8,8 +8,10 @@
import { generateText } from 'ai';
import type { ChatEngine } from '../ChatEngine';
import type { MediaEngine } from '../MediaEngine';
import type { PostEngine } from '../PostEngine';
import { ProviderRegistry } from './providers';
import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n';
import { slugify } from '../slugify';
// ---------------------------------------------------------------------------
// Types
@@ -36,6 +38,14 @@ export interface LanguageDetectionResult {
error?: string;
}
export interface PostAnalysisResult {
success: boolean;
title?: string;
excerpt?: string;
slug?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
@@ -44,15 +54,18 @@ export class OneShotTasks {
private providers: ProviderRegistry;
private chatEngine: ChatEngine;
private mediaEngine: MediaEngine;
private postEngine?: PostEngine;
constructor(
providers: ProviderRegistry,
chatEngine: ChatEngine,
mediaEngine: MediaEngine,
postEngine?: PostEngine,
) {
this.providers = providers;
this.chatEngine = chatEngine;
this.mediaEngine = mediaEngine;
this.postEngine = postEngine;
}
/**
@@ -347,4 +360,86 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
return { success: false, error: (error as Error).message };
}
}
/**
* Analyze a blog post and suggest title, excerpt (plain text), and slug.
* Uses the configured title model (text-only).
*/
async analyzePost(
postId: string,
language: string = 'en',
): Promise<PostAnalysisResult> {
if (!this.postEngine) {
return { success: false, error: 'Post engine not available' };
}
// Load post (resolves content from filesystem for published posts)
const post = await this.postEngine.getPost(postId);
if (!post) return { success: false, error: 'Post not found' };
if (!post.content || post.content.trim().length === 0) {
return { success: false, error: 'Post has no content to analyze' };
}
// Use the title model — lightweight, text-only task
let modelId = await this.chatEngine.getSetting('chat_title_model');
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
// In offline mode, swap to configured offline title model
if (this.providers.isOfflineMode()) {
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|| this.providers.getFirstKnownLocalModelId();
if (offlineModel) {
modelId = offlineModel;
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
}
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
const snippet = post.content.slice(0, 2000);
const renderLanguage = resolveSupportedRenderLanguage(language);
const systemPrompt = translateRender(renderLanguage, 'ai.postAnalysis.system');
const userPrompt = translateRender(renderLanguage, 'ai.postAnalysis.user')
.replace('{title}', post.title || '')
.replace('{content}', snippet);
try {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 500,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
const result = JSON.parse(jsonMatch[0]);
// Sanitize slug: lowercase, hyphens only
let resultSlug = result.slug ? slugify(result.slug) : undefined;
if (resultSlug === '') resultSlug = undefined;
return {
success: true,
title: result.title || undefined,
excerpt: result.excerpt || undefined,
slug: resultSlug,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -91,7 +91,7 @@ function getChatService(): ChatService {
*/
function getOneShotTasks(): OneShotTasks {
if (!oneShotTasks) {
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine);
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine, engineBundle!.postEngine);
}
return oneShotTasks;
}
@@ -894,6 +894,19 @@ export function registerChatHandlers(): void {
}
});
// ============ Post Analysis ============
// Analyze a post and suggest title, excerpt, and slug using AI
ipcMain.handle('chat:analyzePost', async (_, postId: string, language?: string) => {
try {
await ensureInitialized();
return await getOneShotTasks().analyzePost(postId, language || 'en');
} catch (error) {
console.error('[Chat IPC] Error analyzing post:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {

View File

@@ -393,6 +393,9 @@ export const electronAPI: ElectronAPI = {
// Post Language Detection
detectPostLanguage: (title: string, content: string) => ipcRenderer.invoke('chat:detectPostLanguage', title, content),
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => ipcRenderer.invoke('chat:analyzePost', postId, language),
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);

View File

@@ -994,6 +994,9 @@ export interface ElectronAPI {
// Post Language Detection
detectPostLanguage: (title: string, content: string) => Promise<{ success: boolean; language?: string; error?: string }>;
// Post Analysis (title, excerpt, slug suggestions)
analyzePost: (postId: string, language?: string) => Promise<{ success: boolean; title?: string; excerpt?: string; slug?: string; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;

View File

@@ -83,6 +83,8 @@
"render.month.12": "Dezember",
"ai.imageAnalysis.system": "Du erzeugst Bild-Metadaten. Schreibe alle Werte auf Deutsch.\n\nRegeln:\n- \"title\": kurzer beschreibender Titel (3-8 Wörter)\n- \"alt\": sachliche Beschreibung des Sichtbaren (5-12 Wörter). Keine Interpretationen. Kein Präfix \"Bild von\".\n- \"caption\": ansprechende Blog-Bildunterschrift (5-20 Wörter)\n\nAntworte ausschließlich mit JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analysiere dieses Bild. Antworte mit JSON auf Deutsch.",
"ai.postAnalysis.system": "Du bist ein Blog-Redaktionsassistent. Analysiere den folgenden Blogbeitrag und schlage Verbesserungen vor.\n\nGib ein JSON-Objekt zurück mit:\n- \"title\": ein klarer, ansprechender Titel (3-10 Wörter)\n- \"excerpt\": eine Zusammenfassung als Fließtext (2-3 Sätze, kein Markdown, keine Formatierung)\n- \"slug\": ein kurzer, SEO-freundlicher URL-Slug (Kleinbuchstaben, nur Bindestriche, keine Sonderzeichen)\n\nAntworte auf Deutsch. Gib nur das JSON-Objekt zurück.",
"ai.postAnalysis.user": "Titel: {title}\n\nInhalt:\n{content}",
"task.embeddingIndex.name": "Beiträge für semantische Ähnlichkeit indexieren",
"task.embeddingIndex.loading": "Modell wird geladen…",
"task.embeddingIndex.indexing": "Indexierung: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "December",
"ai.imageAnalysis.system": "You generate image metadata. Write all values in English.\n\nRules:\n- \"title\": short descriptive title (3-8 words)\n- \"alt\": factual description of what is visible (5-12 words). No interpretations. No \"Image of\" prefix.\n- \"caption\": engaging blog caption (5-20 words)\n\nRespond with JSON only: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analyze this image. Respond with JSON in English.",
"ai.postAnalysis.system": "You are a blog editor assistant. Analyze the following blog post and suggest improvements.\n\nReturn a JSON object with:\n- \"title\": a clear, engaging title for this post (3-10 words)\n- \"excerpt\": a plain text summary paragraph (2-3 sentences, no markdown, no formatting)\n- \"slug\": a concise, SEO-friendly URL slug (lowercase, hyphens only, no special characters)\n\nRespond in English. Return only the JSON object.",
"ai.postAnalysis.user": "Title: {title}\n\nContent:\n{content}",
"task.embeddingIndex.name": "Index posts for Semantic Similarity",
"task.embeddingIndex.loading": "Loading model…",
"task.embeddingIndex.indexing": "Indexing: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "diciembre",
"ai.imageAnalysis.system": "Generas metadatos de imagen. Escribe todos los valores en español.\n\nReglas:\n- \"title\": título descriptivo corto (3-8 palabras)\n- \"alt\": descripción factual de lo visible (5-12 palabras). Sin interpretaciones. Sin prefijo \"Imagen de\".\n- \"caption\": pie de foto atractivo para blog (5-20 palabras)\n\nResponde solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analiza esta imagen. Responde con JSON en español.",
"ai.postAnalysis.system": "Eres un asistente de edición de blog. Analiza el siguiente artículo y sugiere mejoras.\n\nDevuelve un objeto JSON con:\n- \"title\": un título claro y atractivo (3-10 palabras)\n- \"excerpt\": un párrafo de resumen en texto plano (2-3 oraciones, sin markdown, sin formato)\n- \"slug\": un slug URL conciso y SEO-friendly (minúsculas, solo guiones, sin caracteres especiales)\n\nResponde en español. Devuelve solo el objeto JSON.",
"ai.postAnalysis.user": "Título: {title}\n\nContenido:\n{content}",
"task.embeddingIndex.name": "Indexar entradas para similitud semántica",
"task.embeddingIndex.loading": "Cargando modelo…",
"task.embeddingIndex.indexing": "Indexando: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "décembre",
"ai.imageAnalysis.system": "Tu génères des métadonnées d'image. Écris toutes les valeurs en français.\n\nRègles :\n- \"title\" : titre descriptif court (3-8 mots)\n- \"alt\" : description factuelle de ce qui est visible (5-12 mots). Pas d'interprétations. Pas de préfixe \"Image de\".\n- \"caption\" : légende de blog engageante (5-20 mots)\n\nRéponds uniquement en JSON : {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analyse cette image. Réponds en JSON en français.",
"ai.postAnalysis.system": "Tu es un assistant de rédaction de blog. Analyse l'article suivant et suggère des améliorations.\n\nRetourne un objet JSON avec :\n- \"title\" : un titre clair et engageant (3-10 mots)\n- \"excerpt\" : un paragraphe de résumé en texte brut (2-3 phrases, pas de markdown, pas de formatage)\n- \"slug\" : un slug URL court et SEO-friendly (minuscules, tirets uniquement, pas de caractères spéciaux)\n\nRéponds en français. Retourne uniquement l'objet JSON.",
"ai.postAnalysis.user": "Titre : {title}\n\nContenu :\n{content}",
"task.embeddingIndex.name": "Indexer les articles pour la similarité sémantique",
"task.embeddingIndex.loading": "Chargement du modèle…",
"task.embeddingIndex.indexing": "Indexation : {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "dicembre",
"ai.imageAnalysis.system": "Generi metadati per immagini. Scrivi tutti i valori in italiano.\n\nRegole:\n- \"title\": titolo descrittivo breve (3-8 parole)\n- \"alt\": descrizione fattuale di ciò che è visibile (5-12 parole). Nessuna interpretazione. Nessun prefisso \"Immagine di\".\n- \"caption\": didascalia blog coinvolgente (5-20 parole)\n\nRispondi solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analizza questa immagine. Rispondi con JSON in italiano.",
"ai.postAnalysis.system": "Sei un assistente di redazione blog. Analizza il seguente articolo e suggerisci miglioramenti.\n\nRestituisci un oggetto JSON con:\n- \"title\": un titolo chiaro e coinvolgente (3-10 parole)\n- \"excerpt\": un paragrafo di riassunto in testo semplice (2-3 frasi, no markdown, nessuna formattazione)\n- \"slug\": uno slug URL conciso e SEO-friendly (minuscolo, solo trattini, nessun carattere speciale)\n\nRispondi in italiano. Restituisci solo l'oggetto JSON.",
"ai.postAnalysis.user": "Titolo: {title}\n\nContenuto:\n{content}",
"task.embeddingIndex.name": "Indicizza i post per la similarità semantica",
"task.embeddingIndex.loading": "Caricamento modello…",
"task.embeddingIndex.indexing": "Indicizzazione: {indexed}/{total}",

View File

@@ -191,6 +191,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'),
method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'),
method('chat.analyzePost', 'Analyze a post and generate suggested title, excerpt, and slug using AI.', [requiredString('postId'), optionalString('language')], 'PostAnalysisResult'),
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
@@ -425,6 +426,17 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
{
name: 'PostAnalysisResult',
description: 'Result from AI post analysis containing suggested title, excerpt, and slug.',
fields: [
{ name: 'success', type: 'boolean', required: true, description: 'Whether the analysis succeeded.' },
{ name: 'title', type: 'string', required: false, description: 'Suggested post title.' },
{ name: 'excerpt', type: 'string', required: false, description: 'Suggested plain-text excerpt summarizing the post.' },
{ name: 'slug', type: 'string', required: false, description: 'Suggested URL-friendly slug.' },
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
{
name: 'SimilarPost',
description: 'A post with its semantic similarity score relative to a reference post.',
@@ -453,8 +465,8 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.12.0',
generatedAt: '2026-03-05T00:00:00.000Z',
version: '1.13.0',
generatedAt: '2026-03-07T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useI18n } from '../../i18n';
import './AISuggestionsModal.css';
// Keep legacy types for backward-compatible re-export
export interface AISuggestions {
title?: string;
alt?: string;
@@ -14,52 +15,55 @@ export interface CurrentValues {
caption: string;
}
type SuggestionFieldKey = 'title' | 'alt' | 'caption';
interface SuggestionFieldConfig {
key: SuggestionFieldKey;
/**
* Generic field definition for the AI suggestions modal.
* Each field represents one suggestion the user can accept or reject.
*/
export interface SuggestionField {
key: string;
label: string;
currentValue: string;
suggestedValue?: string;
disabled?: boolean;
warning?: string;
}
const SUGGESTION_FIELDS: SuggestionFieldConfig[] = [
{ key: 'title', label: 'aiSuggestions.titleField' },
{ key: 'alt', label: 'aiSuggestions.altField' },
{ key: 'caption', label: 'aiSuggestions.captionField' },
];
interface AISuggestionsModalProps {
isOpen: boolean;
isLoading: boolean;
suggestions: AISuggestions | null;
currentValues: CurrentValues;
fields: SuggestionField[];
error?: string;
onConfirm: (values: Partial<AISuggestions>) => void;
modalTitle: string;
loadingText: string;
emptyText: string;
onConfirm: (values: Record<string, string>) => void;
onClose: () => void;
}
export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
isOpen,
isLoading,
suggestions,
currentValues,
fields,
error,
modalTitle,
loadingText,
emptyText,
onConfirm,
onClose,
}) => {
const { t: tr } = useI18n();
// Checkbox state - initialized based on whether current values are empty
const [useTitle, setUseTitle] = useState(false);
const [useAlt, setUseAlt] = useState(false);
const [useCaption, setUseCaption] = useState(false);
// Dynamic checkbox state keyed by field key
const [checked, setChecked] = useState<Record<string, boolean>>({});
// Update checkbox state when suggestions arrive, based on whether current fields are empty
// Auto-check fields when suggestions arrive:
// checked only when there IS a suggestion AND current value is empty
useEffect(() => {
if (suggestions) {
setUseTitle(suggestions.title ? !currentValues.title : false);
setUseAlt(suggestions.alt ? !currentValues.alt : false);
setUseCaption(suggestions.caption ? !currentValues.caption : false);
const initial: Record<string, boolean> = {};
for (const field of fields) {
initial[field.key] = !field.disabled && !!field.suggestedValue && !field.currentValue;
}
}, [suggestions, currentValues]);
setChecked(initial);
}, [fields]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isLoading) {
@@ -68,68 +72,30 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
}, [isLoading, onClose]);
const handleConfirm = useCallback(() => {
const valuesToApply: Partial<AISuggestions> = {};
if (useTitle && suggestions?.title) valuesToApply.title = suggestions.title;
if (useAlt && suggestions?.alt) valuesToApply.alt = suggestions.alt;
if (useCaption && suggestions?.caption) valuesToApply.caption = suggestions.caption;
const valuesToApply: Record<string, string> = {};
for (const field of fields) {
if (checked[field.key] && field.suggestedValue) {
valuesToApply[field.key] = field.suggestedValue;
}
}
onConfirm(valuesToApply);
}, [useTitle, useAlt, useCaption, suggestions, onConfirm]);
}, [checked, fields, onConfirm]);
const setFieldChecked = useCallback((key: string, value: boolean) => {
setChecked(prev => ({ ...prev, [key]: value }));
}, []);
if (!isOpen) return null;
const hasAnySuggestion = suggestions && (suggestions.title || suggestions.alt || suggestions.caption);
const hasAnySelected = useTitle || useAlt || useCaption;
const fieldSelection: Record<SuggestionFieldKey, [boolean, (checked: boolean) => void]> = {
title: [useTitle, setUseTitle],
alt: [useAlt, setUseAlt],
caption: [useCaption, setUseCaption],
};
const renderSuggestionField = (field: SuggestionFieldConfig) => {
if (!suggestions?.[field.key]) {
return null;
}
const [isChecked, setChecked] = fieldSelection[field.key];
const currentValue = currentValues[field.key];
const suggestedValue = suggestions[field.key];
return (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setChecked(e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{currentValue && (
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{suggestedValue}</div>
{currentValue && (
<div className="ai-suggestion-current">
{tr('aiSuggestions.current')}: <em>{currentValue}</em>
</div>
)}
</div>
</div>
);
};
const fieldsWithSuggestions = fields.filter(f => !!f.suggestedValue);
const hasAnySuggestion = fieldsWithSuggestions.length > 0;
const hasAnySelected = Object.values(checked).some(v => v);
return (
<div className="ai-suggestions-modal-backdrop" onClick={handleBackdropClick}>
<div className="ai-suggestions-modal">
<div className="ai-suggestions-modal-header">
<h2>{tr('aiSuggestions.title')}</h2>
<h2>{modalTitle}</h2>
{!isLoading && (
<button className="ai-suggestions-modal-close" onClick={onClose} title={tr('aiSuggestions.close')}>
@@ -141,7 +107,7 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
{isLoading && (
<div className="ai-suggestions-loading">
<div className="ai-suggestions-spinner"></div>
<p>{tr('aiSuggestions.analyzing')}</p>
<p>{loadingText}</p>
</div>
)}
@@ -157,13 +123,44 @@ export const AISuggestionsModal: React.FC<AISuggestionsModalProps> = ({
<p className="ai-suggestions-intro">
{tr('aiSuggestions.intro')}
</p>
{SUGGESTION_FIELDS.map((field) => renderSuggestionField({ ...field, label: tr(field.label) }))}
{fieldsWithSuggestions.map((field) => (
<div key={field.key} className="ai-suggestion-item">
<label className="ai-suggestion-checkbox">
<input
type="checkbox"
checked={!!checked[field.key]}
disabled={field.disabled}
onChange={(e) => setFieldChecked(field.key, e.target.checked)}
/>
<span className="checkmark"></span>
</label>
<div className="ai-suggestion-content">
<div className="ai-suggestion-label">
{field.label}
{field.currentValue && (
<span className="ai-suggestion-has-value" title={tr('aiSuggestions.hasExisting')}>
{tr('aiSuggestions.hasExisting')}
</span>
)}
</div>
<div className="ai-suggestion-value">{field.suggestedValue}</div>
{field.warning && (
<div className="ai-suggestion-current">{field.warning}</div>
)}
{field.currentValue && (
<div className="ai-suggestion-current">
{tr('aiSuggestions.current')}: <em>{field.currentValue}</em>
</div>
)}
</div>
</div>
))}
</div>
)}
{!isLoading && !error && !hasAnySuggestion && suggestions && (
{!isLoading && !error && !hasAnySuggestion && fields.length > 0 && (
<div className="ai-suggestions-empty">
{tr('aiSuggestions.empty')}
{emptyText}
</div>
)}
</div>

View File

@@ -147,6 +147,12 @@
align-items: flex-start;
}
.editor-excerpt-panel {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor-meta {
display: flex;
flex-direction: column;

View File

@@ -24,7 +24,8 @@ import { TemplatesView } from '../TemplatesView/TemplatesView';
import { DuplicatesView } from '../DuplicatesView/DuplicatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
import type { SuggestionField } from '../AISuggestionsModal/AISuggestionsModal';
import { openEntityTab } from '../../navigation/tabPolicy';
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
@@ -67,6 +68,7 @@ const autoSaveManager = new AutoSaveManager({
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
if ('title' in changes) update.title = changes.title as string;
if ('content' in changes) update.content = changes.content as string;
if ('excerpt' in changes) update.excerpt = changes.excerpt as string;
if ('tags' in changes) {
const tagsStr = changes.tags as string;
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
@@ -193,6 +195,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [excerpt, setExcerpt] = useState('');
const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
@@ -209,11 +212,21 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false);
const [metadataExpanded, setMetadataExpanded] = useState(true);
const [excerptExpanded, setExcerptExpanded] = useState(false);
const editorRef = useRef<unknown>(null);
// Token incremented to signal Monaco that it should re-read its defaultValue.
// This is used instead of controlled `value` to avoid cursor-reset races.
const [monacoResetToken, setMonacoResetToken] = useState(0);
// Quick actions state for AI post analysis
const [showPostQuickActions, setShowPostQuickActions] = useState(false);
const [projectLanguage, setProjectLanguage] = useState('en');
const postQuickActionsRef = useRef<HTMLDivElement>(null);
const [showPostAISuggestionsModal, setShowPostAISuggestionsModal] = useState(false);
const [isAnalyzingPost, setIsAnalyzingPost] = useState(false);
const [postAISuggestionFields, setPostAISuggestionFields] = useState<SuggestionField[]>([]);
const [postAIError, setPostAIError] = useState<string | undefined>(undefined);
const isDirty = checkIsDirty(postId);
// Listen for auto-save events to keep local post state in sync
@@ -325,6 +338,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
if (post && !isInitialized) {
setTitle(post.title);
setContent(post.content);
setExcerpt(post.excerpt || '');
setAuthor(post.author || '');
setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
@@ -349,10 +363,11 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// Short-circuit: check cheap comparisons first (content changes on every keystroke)
const contentChanged = content !== post.content;
const titleChanged = title !== post.title;
const excerptChanged = excerpt !== (post.excerpt || '');
const authorChanged = author !== (post.author || '');
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const languageChanged = postLanguage !== (post.language || '');
const hasChanges = contentChanged || titleChanged || authorChanged || templateSlugChanged || languageChanged ||
const hasChanges = contentChanged || titleChanged || excerptChanged || authorChanged || templateSlugChanged || languageChanged ||
JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
@@ -363,6 +378,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
autoSaveManager.notifyChange(postId, {
title,
content,
excerpt,
author,
tags: tags.join(', '),
categories: selectedCategories,
@@ -372,7 +388,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
} else {
markClean(postId);
}
}, [title, content, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]);
}, [title, content, excerpt, author, tags, selectedCategories, templateSlug, postLanguage, post, postId, isInitialized, isDirty, markDirty, markClean]);
// Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => {
@@ -391,6 +407,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const updated = await window.electronAPI?.posts.update(postId, {
title,
content,
excerpt: excerpt || undefined,
author: author || undefined,
language: postLanguage || undefined,
tags,
@@ -434,6 +451,100 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setIsDetectingLanguage(false);
}
}, [title, content, isDetectingLanguage, tr]);
// Load project language for AI post analysis
useEffect(() => {
window.electronAPI?.meta?.getProjectMetadata?.()?.then(metadata => {
if (metadata?.mainLanguage) {
setProjectLanguage(metadata.mainLanguage);
}
});
}, []);
// Close quick actions menu when clicking outside
useEffect(() => {
if (!showPostQuickActions) return;
const handleClickOutside = (e: MouseEvent) => {
if (postQuickActionsRef.current && !postQuickActionsRef.current.contains(e.target as Node)) {
setShowPostQuickActions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPostQuickActions]);
// Handle AI post analysis (title, excerpt, slug suggestions)
const handlePostAIAnalysis = useCallback(async () => {
if (!post || isAnalyzingPost) return;
setShowPostQuickActions(false);
setShowPostAISuggestionsModal(true);
setIsAnalyzingPost(true);
setPostAISuggestionFields([]);
setPostAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzePost(postId, projectLanguage);
if (result?.success) {
const slugLocked = !!post.publishedAt;
setPostAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'excerpt', label: tr('aiSuggestions.excerptField'), currentValue: excerpt, suggestedValue: result.excerpt },
{
key: 'slug',
label: tr('aiSuggestions.slugField'),
currentValue: post.slug,
suggestedValue: result.slug,
disabled: slugLocked,
warning: slugLocked ? tr('aiSuggestions.slugLockedWarning') : undefined,
},
]);
} else {
setPostAIError(result?.error || tr('editor.post.error.analyzePost'));
}
} catch (error) {
console.error('Failed to analyze post:', error);
setPostAIError((error as Error).message || tr('editor.post.error.analyzePost'));
} finally {
setIsAnalyzingPost(false);
}
}, [post, postId, projectLanguage, isAnalyzingPost, title, excerpt, tr]);
// Handle applying AI post suggestions
const handleApplyPostAISuggestions = useCallback(async (values: Record<string, string>) => {
setShowPostAISuggestionsModal(false);
if (Object.keys(values).length === 0) return;
try {
const updatePayload: Record<string, unknown> = {};
if (values.title) updatePayload.title = values.title;
if (values.excerpt) updatePayload.excerpt = values.excerpt;
if (values.slug && !post?.publishedAt) updatePayload.slug = values.slug;
const updated = await window.electronAPI?.posts.update(postId, updatePayload as Parameters<typeof window.electronAPI.posts.update>[1]);
if (updated) {
updatePost(postId, updated as Partial<PostData>);
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
// Update local state for fields that changed
if (values.title) setTitle(values.title);
if (values.excerpt) setExcerpt(values.excerpt);
markDirty(postId);
showToast.success(tr('editor.post.toast.aiApplied'));
}
} catch (error) {
console.error('Failed to apply AI suggestions:', error);
showToast.error(tr('editor.post.error.applyFailed'));
}
}, [post, postId, updatePost, markDirty, tr]);
// Close AI post suggestions modal
const handleClosePostAISuggestionsModal = useCallback(() => {
setShowPostAISuggestionsModal(false);
setPostAISuggestionFields([]);
setPostAIError(undefined);
}, []);
const handlePublish = async () => {
await handleSave();
try {
@@ -743,9 +854,34 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
{post.status}
</span>
{isSaving && <span className="auto-save-indicator">{tr('editor.saving')}</span>}
<div className="quick-actions-wrapper" ref={postQuickActionsRef}>
<button
className="secondary quick-actions-btn"
onClick={() => setShowPostQuickActions(!showPostQuickActions)}
disabled={isAnalyzingPost}
title={tr('editor.post.quickActions.title')}
>
{isAnalyzingPost ? tr('editor.post.quickActions.analyzing') : tr('editor.post.quickActions.button')}
</button>
{showPostQuickActions && (
<div className="quick-actions-menu">
<button
className="quick-action-item"
onClick={handlePostAIAnalysis}
disabled={isAnalyzingPost || !content}
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>{tr('editor.post.quickActions.aiTitle')}</strong>
<small>{tr('editor.post.quickActions.aiDescription')}</small>
</span>
</button>
</div>
)}
</div>
{post.status === 'draft' && (
<button
onClick={handlePublish}
<button
onClick={handlePublish}
className="success"
title={tr('editor.publishTitle')}
>
@@ -880,6 +1016,27 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
</div>
</div>
)}
<button
className={`metadata-toggle ${excerptExpanded ? 'expanded' : ''}`}
onClick={() => setExcerptExpanded(v => !v)}
>
<span className="metadata-toggle-chevron">{excerptExpanded ? '▼' : '▶'}</span>
<span>{tr('editor.excerpt.toggle')}</span>
</button>
{excerptExpanded && (
<div className="editor-excerpt-panel">
<div className="editor-field">
<label>{tr('editor.field.excerpt')}</label>
<textarea
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
placeholder={tr('editor.placeholder.excerpt')}
rows={4}
/>
</div>
</div>
)}
<div className="editor-body">
<div className="editor-toolbar">
@@ -1038,6 +1195,19 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowMediaSearch(false)}
/>
)}
{/* AI Post Suggestions Modal */}
<AISuggestionsModal
isOpen={showPostAISuggestionsModal}
isLoading={isAnalyzingPost}
fields={postAISuggestionFields}
modalTitle={tr('aiSuggestions.postTitle')}
loadingText={tr('aiSuggestions.analyzingPost')}
emptyText={tr('aiSuggestions.postEmpty')}
error={postAIError}
onConfirm={handleApplyPostAISuggestions}
onClose={handleClosePostAISuggestionsModal}
/>
</div>
);
};
@@ -1067,7 +1237,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiSuggestions, setAISuggestions] = useState<AISuggestions | null>(null);
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
const [aiError, setAIError] = useState<string | undefined>(undefined);
// Load project language setting
@@ -1096,22 +1266,22 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// Handle AI image analysis for alt text and caption
const handleAIAnalysis = async () => {
if (!item || isAnalyzing) return;
setShowQuickActions(false);
setShowAISuggestionsModal(true);
setIsAnalyzing(true);
setAISuggestions(null);
setAISuggestionFields([]);
setAIError(undefined);
try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
setAISuggestions({
title: result.title,
alt: result.alt,
caption: result.caption,
});
setAISuggestionFields([
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
]);
} else {
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
}
@@ -1124,7 +1294,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
};
// Handle applying AI suggestions
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => {
const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption);
@@ -1551,8 +1721,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<AISuggestionsModal
isOpen={showAISuggestionsModal}
isLoading={isAnalyzing}
suggestions={aiSuggestions}
currentValues={{ title, alt, caption }}
fields={aiSuggestionFields}
modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError}
onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal}

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.",
"aiSuggestions.wait": "Bitte warten...",
"aiSuggestions.applySelected": "Ausgewählte übernehmen",
"aiSuggestions.postTitle": "KI-Beitragsanalyse",
"aiSuggestions.analyzingPost": "Beitrag wird analysiert…",
"aiSuggestions.excerptField": "Zusammenfassung / Auszug",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Der Slug kann nur vor der ersten Veröffentlichung geändert werden.",
"aiSuggestions.postEmpty": "Für diesen Beitrag wurden keine Vorschläge generiert.",
"insert.title.link": "Link einfügen",
"insert.title.image": "Bild einfügen",
"insert.tab.linkInternal": "Mit Beitrag verlinken",
@@ -545,6 +551,7 @@
"editor.field.tags": "Schlagwörter",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Auszug",
"editor.field.categories": "Kategorien",
"editor.field.content": "Inhalt",
"editor.field.template": "Vorlage",
@@ -558,6 +565,7 @@
"language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname",
"editor.placeholder.excerpt": "Optionale Zusammenfassung für Listen und Vorschauen",
"editor.placeholder.categories": "Kategorien hinzufügen...",
"editor.placeholder.startWriting": "Mit dem Schreiben beginnen...",
"editor.mode.visual": "Visuell",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert",
"editor.footer.published": "Veröffentlicht",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
"editor.post.quickActions.title": "Schnellaktionen",
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
"editor.post.quickActions.button": "⚡ Schnellaktionen",
"editor.post.quickActions.aiTitle": "KI: Titel, Zusammenfassung & Slug vorschlagen",
"editor.post.quickActions.aiDescription": "Analysiert den Inhalt und schlägt Metadaten vor",
"editor.post.quickActions.detectLanguageDescription": "Sprache mit KI erkennen",
"editor.post.quickActions.detecting": "Erkennung…",
"editor.post.quickActions.languageDetected": "Sprache erkannt",
"editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen",
"editor.post.error.analyzePost": "Beitragsanalyse fehlgeschlagen",
"editor.post.error.applyFailed": "KI-Vorschläge konnten nicht angewendet werden",
"editor.post.toast.aiApplied": "KI-Vorschläge angewendet",
"editor.media.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No suggestions were generated for this image.",
"aiSuggestions.wait": "Please wait...",
"aiSuggestions.applySelected": "Apply Selected",
"aiSuggestions.postTitle": "AI Post Analysis",
"aiSuggestions.analyzingPost": "Analyzing post…",
"aiSuggestions.excerptField": "Summary / Excerpt",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Slug can only be changed before the first publish.",
"aiSuggestions.postEmpty": "No suggestions were generated for this post.",
"insert.title.link": "Insert Link",
"insert.title.image": "Insert Image",
"insert.tab.linkInternal": "Link to Post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tags",
"editor.field.author": "Author",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Excerpt",
"editor.field.categories": "Categories",
"editor.field.content": "Content",
"editor.field.template": "Template",
@@ -558,6 +565,7 @@
"language.es": "Spanish",
"editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name",
"editor.placeholder.excerpt": "Optional summary for lists and previews",
"editor.placeholder.categories": "Add categories...",
"editor.placeholder.startWriting": "Start writing...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created",
"editor.footer.updated": "Updated",
"editor.footer.published": "Published",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
"editor.post.quickActions.title": "Quick Actions",
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
"editor.post.quickActions.button": "⚡ Quick Actions",
"editor.post.quickActions.aiTitle": "AI: Suggest Title, Summary & Slug",
"editor.post.quickActions.aiDescription": "Analyzes post content to suggest metadata",
"editor.post.quickActions.detectLanguageDescription": "Detect language using AI",
"editor.post.quickActions.detecting": "Detecting…",
"editor.post.quickActions.languageDetected": "Language detected",
"editor.post.quickActions.detectLanguageFailed": "Language detection failed",
"editor.post.error.analyzePost": "Failed to analyze post",
"editor.post.error.applyFailed": "Failed to apply AI suggestions",
"editor.post.toast.aiApplied": "AI suggestions applied",
"editor.media.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No se generaron sugerencias para esta imagen.",
"aiSuggestions.wait": "Por favor espera...",
"aiSuggestions.applySelected": "Aplicar seleccionados",
"aiSuggestions.postTitle": "Análisis IA del artículo",
"aiSuggestions.analyzingPost": "Analizando artículo…",
"aiSuggestions.excerptField": "Resumen / Extracto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "El slug solo puede cambiarse antes de la primera publicación.",
"aiSuggestions.postEmpty": "No se generaron sugerencias para este artículo.",
"insert.title.link": "Insertar enlace",
"insert.title.image": "Insertar imagen",
"insert.tab.linkInternal": "Enlazar a entrada",
@@ -545,6 +551,7 @@
"editor.field.tags": "Etiquetas",
"editor.field.author": "Autor",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extracto",
"editor.field.categories": "Categorías",
"editor.field.content": "Contenido",
"editor.field.template": "Plantilla",
@@ -558,6 +565,7 @@
"language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor",
"editor.placeholder.excerpt": "Resumen opcional para listas y vistas previas",
"editor.placeholder.categories": "Agregar categorías...",
"editor.placeholder.startWriting": "Empieza a escribir...",
"editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado",
"editor.footer.published": "Publicado",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
"editor.post.quickActions.title": "Acciones rápidas",
"editor.post.quickActions.analyzing": "⏳ Analizando…",
"editor.post.quickActions.button": "⚡ Acciones rápidas",
"editor.post.quickActions.aiTitle": "IA: Sugerir título, resumen y slug",
"editor.post.quickActions.aiDescription": "Analiza el contenido para sugerir metadatos",
"editor.post.quickActions.detectLanguageDescription": "Detectar idioma con IA",
"editor.post.quickActions.detecting": "Detectando…",
"editor.post.quickActions.languageDetected": "Idioma detectado",
"editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma",
"editor.post.error.analyzePost": "Error al analizar el artículo",
"editor.post.error.applyFailed": "No se pudieron aplicar las sugerencias de IA",
"editor.post.toast.aiApplied": "Sugerencias de IA aplicadas",
"editor.media.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Aucune suggestion na été générée pour cette image.",
"aiSuggestions.wait": "Veuillez patienter...",
"aiSuggestions.applySelected": "Appliquer la sélection",
"aiSuggestions.postTitle": "Analyse IA de l'article",
"aiSuggestions.analyzingPost": "Analyse de l'article…",
"aiSuggestions.excerptField": "Résumé / Extrait",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Le slug ne peut être modifié qu'avant la première publication.",
"aiSuggestions.postEmpty": "Aucune suggestion n'a été générée pour cet article.",
"insert.title.link": "Insérer un lien",
"insert.title.image": "Insérer une image",
"insert.tab.linkInternal": "Lier à un article",
@@ -545,6 +551,7 @@
"editor.field.tags": "Étiquettes",
"editor.field.author": "Auteur",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Extrait",
"editor.field.categories": "Catégories",
"editor.field.content": "Contenu",
"editor.field.template": "Modèle",
@@ -558,6 +565,7 @@
"language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur",
"editor.placeholder.excerpt": "Résumé facultatif pour les listes et aperçus",
"editor.placeholder.categories": "Ajouter des catégories...",
"editor.placeholder.startWriting": "Commencez à écrire...",
"editor.mode.visual": "Visuel",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour",
"editor.footer.published": "Publié",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par lIA",
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
"editor.post.quickActions.title": "Actions rapides",
"editor.post.quickActions.analyzing": "⏳ Analyse…",
"editor.post.quickActions.button": "⚡ Actions rapides",
"editor.post.quickActions.aiTitle": "IA : Suggérer titre, résumé et slug",
"editor.post.quickActions.aiDescription": "Analyse le contenu pour suggérer des métadonnées",
"editor.post.quickActions.detectLanguageDescription": "Détecter la langue avec l'IA",
"editor.post.quickActions.detecting": "Détection…",
"editor.post.quickActions.languageDetected": "Langue détectée",
"editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue",
"editor.post.error.analyzePost": "Échec de l'analyse de l'article",
"editor.post.error.applyFailed": "Impossible d'appliquer les suggestions IA",
"editor.post.toast.aiApplied": "Suggestions IA appliquées",
"editor.media.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.",
"aiSuggestions.wait": "Attendere...",
"aiSuggestions.applySelected": "Applica selezionati",
"aiSuggestions.postTitle": "Analisi IA dell'articolo",
"aiSuggestions.analyzingPost": "Analisi dell'articolo…",
"aiSuggestions.excerptField": "Riassunto / Estratto",
"aiSuggestions.slugField": "Slug",
"aiSuggestions.slugLockedWarning": "Lo slug può essere modificato solo prima della prima pubblicazione.",
"aiSuggestions.postEmpty": "Nessun suggerimento generato per questo articolo.",
"insert.title.link": "Inserisci link",
"insert.title.image": "Inserisci immagine",
"insert.tab.linkInternal": "Collega al post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tag",
"editor.field.author": "Autore",
"editor.field.slug": "Slug",
"editor.field.excerpt": "Estratto",
"editor.field.categories": "Categorie",
"editor.field.content": "Contenuto",
"editor.field.template": "Modello",
@@ -558,6 +565,7 @@
"language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore",
"editor.placeholder.excerpt": "Riassunto facoltativo per elenchi e anteprime",
"editor.placeholder.categories": "Aggiungi categorie...",
"editor.placeholder.startWriting": "Inizia a scrivere...",
"editor.mode.visual": "Visuale",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato",
"editor.footer.published": "Pubblicato",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
"editor.post.quickActions.title": "Azioni rapide",
"editor.post.quickActions.analyzing": "⏳ Analisi…",
"editor.post.quickActions.button": "⚡ Azioni rapide",
"editor.post.quickActions.aiTitle": "IA: Suggerisci titolo, riassunto e slug",
"editor.post.quickActions.aiDescription": "Analizza il contenuto per suggerire i metadati",
"editor.post.quickActions.detectLanguageDescription": "Rileva la lingua con l'IA",
"editor.post.quickActions.detecting": "Rilevamento…",
"editor.post.quickActions.languageDetected": "Lingua rilevata",
"editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito",
"editor.post.error.analyzePost": "Analisi dell'articolo fallita",
"editor.post.error.applyFailed": "Impossibile applicare i suggerimenti IA",
"editor.post.toast.aiApplied": "Suggerimenti IA applicati",
"editor.media.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo",