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:
@@ -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)}" />`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -229,6 +229,12 @@
|
||||
"aiSuggestions.empty": "Aucune suggestion n’a é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 l’auteur",
|
||||
"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 l’article",
|
||||
"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 l’IA",
|
||||
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
||||
"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",
|
||||
|
||||
@@ -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 dall’IA",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user