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

52
API.md
View File

@@ -1,6 +1,6 @@
# API Documentation # API Documentation
Contract version: 1.12.0 Contract version: 1.13.0
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
@@ -3513,6 +3513,7 @@ result = await bds.tags.sync_from_posts()
- [chat.analyzeMediaImage](#chatanalyzemediaimage) - [chat.analyzeMediaImage](#chatanalyzemediaimage)
- [chat.detectPostLanguage](#chatdetectpostlanguage) - [chat.detectPostLanguage](#chatdetectpostlanguage)
- [chat.analyzePost](#chatanalyzepost)
### chat.analyzeMediaImage ### chat.analyzeMediaImage
@@ -3573,6 +3574,39 @@ result = await bds.chat.detect_post_language(title='title', content='content')
{} {}
``` ```
### chat.analyzePost
Analyze a post and generate suggested title, excerpt, and slug using AI.
**Parameters**
- postId (str, required)
- language (str, optional)
**Response specification**
- Return type: `PostAnalysisResult`
- Data structures: `PostAnalysisResult`
**Example call**
```python
from bds_api import bds
result = await bds.chat.analyze_post(post_id='post-1')
```
**Example response**
```python
{
'success': False,
'title': 'value',
'excerpt': 'value',
'slug': 'value',
'error': 'value'
}
```
[↑ Back to Table of contents](#table-of-contents) [↑ Back to Table of contents](#table-of-contents)
## sync ## sync
@@ -4375,6 +4409,20 @@ Result from AI image analysis containing generated title, alt text, and caption.
[↑ Back to Table of contents](#table-of-contents) [↑ Back to Table of contents](#table-of-contents)
### PostAnalysisResult
Result from AI post analysis containing suggested title, excerpt, and slug.
**Fields**
- success (`boolean`, required): Whether the analysis succeeded.
- title (`string`, optional): Suggested post title.
- excerpt (`string`, optional): Suggested plain-text excerpt summarizing the post.
- slug (`string`, optional): Suggested URL-friendly slug.
- error (`string`, optional): Error message when analysis failed.
[↑ Back to Table of contents](#table-of-contents)
### SimilarPost ### SimilarPost
A post with its semantic similarity score relative to a reference post. A post with its semantic similarity score relative to a reference post.
@@ -4411,4 +4459,4 @@ A pair of posts with high content similarity that may be duplicates.
--- ---
Generated from contract at 2026-03-05T00:00:00.000Z. Generated from contract at 2026-03-07T00:00:00.000Z.

View File

@@ -103,6 +103,12 @@ A post usually combines several layers of information: title, body content, cate
A reliable post workflow starts by drafting content to completion, then reviewing structure and metadata, and finally previewing the output before publishing. After publishing, commit in Source Control immediately so the editorially approved state is recoverable and shareable. A reliable post workflow starts by drafting content to completion, then reviewing structure and metadata, and finally previewing the output before publishing. After publishing, commit in Source Control immediately so the editorially approved state is recoverable and shareable.
When you want help refining post metadata, use the **Quick Actions** button in the post editor and choose the AI suggestion action for title, summary, and slug. bDS analyzes the current post content and opens a review dialog that shows the suggested values next to the current ones. You can apply only the fields you want, so this is best used as an editorial assistant rather than an automatic rewrite step.
The generated summary is stored in the post excerpt field. In the editor, the excerpt appears in its own collapsible block between the metadata area and the main body, and it starts closed by default. Open this block when you want to review or manually adjust the summary text for list pages, previews, and other overview contexts.
This feature has a few practical limits. It depends on AI being configured in bDS, so it is not available in projects that are using only local editing without AI access. Slug suggestions can only be applied before the post has ever been published. After the first publish, the slug remains locked to protect existing URLs, so the dialog will still show the suggestion for reference but will not let you apply it.
### Key takeaways ### Key takeaways
- Use Posts for date-oriented, regularly updated content. - Use Posts for date-oriented, regularly updated content.

102
TODO.md
View File

@@ -125,108 +125,6 @@ In `PageRenderer` and `BlogGenerationEngine`:
--- ---
## 2. AI Post Summary, Title & Slug Suggestions
### Goal
The post editor has AI buttons that generate summaries (excerpts), improved
titles, and better slugs — so the user can focus on writing content and let AI
handle the metadata.
### Current State
- `analyzeMediaImage()` in `OpenCodeManager` already implements the exact
pattern: one-shot AI call, JSON response, language-aware.
- `AISuggestionsModal` already provides the UI: loading state, field-by-field
checkboxes, current vs. suggested comparison, apply/cancel.
- The media editor has an "Analyze with AI" button in a quick-actions menu.
- The post editor metadata area has title, tags, author, slug, and categories
fields but no AI buttons.
- The `excerpt` field exists on `PostData` and can serve as the summary.
- Slug is read-only in the UI after first publish (auto-generated from title).
### Implementation Plan
#### 2.1 Backend — `analyzePost()` in OpenCodeManager
Add a new method following the `analyzeMediaImage()` pattern:
**Input:** `postId: string, language: string`
**Process:**
1. Load post content, title, excerpt, and slug via `PostEngine`.
2. Build a system prompt:
```
You are a blog editor assistant. Analyze the following blog post and suggest
improvements. Return a JSON object with:
- "title": a clear, engaging title for this post
- "excerpt": a 2-3 sentence summary suitable for overview pages
- "slug": a concise, SEO-friendly URL slug (lowercase, hyphens only)
Respond in {language}. Return only the JSON object.
```
3. Send post content as user message to OpenCode Zen API.
4. Parse JSON response.
5. Return `{ success, title?, excerpt?, slug?, error? }`.
Register IPC handler: `chat:analyzePost`.
#### 2.2 Frontend — Post Editor AI Button
In the post editor metadata area (`Editor.tsx`, around line 720):
- Add a "Quick Actions" dropdown button (same pattern as media editor at
line 1242).
- Menu item: "Suggest Title, Summary & Slug" with a robot icon.
- On click: call `window.electronAPI.chat.analyzePost(postId, projectLanguage)`.
- Show `AISuggestionsModal` with the results.
#### 2.3 Extend AISuggestionsModal
The modal currently supports `title`, `alt`, `caption` fields. Adapt it to
also support a post mode with `title`, `excerpt`, `slug` fields:
- Add a `mode` prop (`'media'` | `'post'`) or make field configuration
dynamic.
- For post mode, show title, excerpt, and slug fields.
- Slug field should show a warning that it only applies to unpublished posts.
Alternatively, keep the modal generic and pass field definitions as props:
```typescript
interface SuggestionField {
key: string;
label: string; // i18n key
currentValue: string;
suggestedValue?: string;
warning?: string; // e.g., "slug is locked after first publish"
}
```
#### 2.4 Applying Suggestions
On "Apply Selected":
- Title: update via existing `onTitleChange` handler.
- Excerpt: update via `onExcerptChange` (may need to add this handler if not
present — excerpt editing may need a field in the metadata area).
- Slug: only apply if post has never been published. Show a warning and disable
the checkbox if the post has `publishedAt` set.
#### 2.5 i18n
Add keys to all 5 locale files:
- `aiSuggestions.postTitle`, `aiSuggestions.excerptField`,
`aiSuggestions.slugField`
- `aiSuggestions.analyzingPost`
- `aiSuggestions.slugLockedWarning`
- `postEditor.quickActions`, `postEditor.analyzeWithAI`
#### 2.6 Excerpt Field in Editor
If the excerpt/summary is not currently editable in the post metadata area,
add a multi-line text field for it between title and tags. This is needed both
for manual editing and for applying AI suggestions.
--- ---
## 3. Drag-and-Drop Image Insertion ## 3. Drag-and-Drop Image Insertion

View File

@@ -156,6 +156,13 @@ function excerptToXhtml(post: PostData): string {
return paragraphToXhtml(firstParagraph); 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 { function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>'); return value.replace(/]]>/g, ']]]]><![CDATA[>');
} }
@@ -384,7 +391,7 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug); const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`; const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post); const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || ''); const contentXhtml = feedContentToXhtml(post);
const categories = [ const categories = [
...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`), ...(post.categories || []).map((category) => `<category>${escapeXml(category)}</category>`),
...(post.tags || []).map((tag) => `<category>${escapeXml(tag)}</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 canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
const permalink = `${baseUrl}${canonicalPath}`; const permalink = `${baseUrl}${canonicalPath}`;
const excerptXhtml = excerptToXhtml(post); const excerptXhtml = excerptToXhtml(post);
const contentXhtml = markdownToXhtml(post.content || ''); const contentXhtml = feedContentToXhtml(post);
const categories = [ const categories = [
...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`), ...(post.tags || []).map((tag) => `<category term="${escapeXml(tag)}" />`),
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`), ...(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) // automatically transition to draft status (content moves from file to DB)
const isContentOrMetadataChange = data.content !== undefined || const isContentOrMetadataChange = data.content !== undefined ||
data.title !== undefined || data.title !== undefined ||
data.slug !== undefined ||
data.tags !== undefined || data.tags !== undefined ||
data.categories !== undefined || data.categories !== undefined ||
data.excerpt !== undefined || data.excerpt !== undefined ||
@@ -459,12 +460,32 @@ export class PostEngine extends EventEmitter {
newStatus = 'draft'; newStatus = 'draft';
} }
// Auto-update slug when title changes, but only if post was never published // Explicit slug changes are only allowed before the first publish.
let newSlug = data.slug ?? existing.slug; const requestedSlug = typeof data.slug === 'string' ? slugify(data.slug) : undefined;
if (data.title !== undefined && data.title !== existing.title && !existing.publishedAt) { 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); 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 = { const updated: PostData = {
...existing, ...existing,
...data, ...data,
@@ -478,21 +499,25 @@ export class PostEngine extends EventEmitter {
const checksum = this.calculateChecksum(updated.content); const checksum = this.calculateChecksum(updated.content);
// All updates go to DB only — no file writes // 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) await db.update(posts)
.set({ .set(dbSet)
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,
})
.where(eq(posts.id, id)); .where(eq(posts.id, id));
// Update FTS index // Update FTS index

View File

@@ -8,8 +8,10 @@
import { generateText } from 'ai'; import { generateText } from 'ai';
import type { ChatEngine } from '../ChatEngine'; import type { ChatEngine } from '../ChatEngine';
import type { MediaEngine } from '../MediaEngine'; import type { MediaEngine } from '../MediaEngine';
import type { PostEngine } from '../PostEngine';
import { ProviderRegistry } from './providers'; import { ProviderRegistry } from './providers';
import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n'; import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n';
import { slugify } from '../slugify';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -36,6 +38,14 @@ export interface LanguageDetectionResult {
error?: string; error?: string;
} }
export interface PostAnalysisResult {
success: boolean;
title?: string;
excerpt?: string;
slug?: string;
error?: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OneShotTasks // OneShotTasks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -44,15 +54,18 @@ export class OneShotTasks {
private providers: ProviderRegistry; private providers: ProviderRegistry;
private chatEngine: ChatEngine; private chatEngine: ChatEngine;
private mediaEngine: MediaEngine; private mediaEngine: MediaEngine;
private postEngine?: PostEngine;
constructor( constructor(
providers: ProviderRegistry, providers: ProviderRegistry,
chatEngine: ChatEngine, chatEngine: ChatEngine,
mediaEngine: MediaEngine, mediaEngine: MediaEngine,
postEngine?: PostEngine,
) { ) {
this.providers = providers; this.providers = providers;
this.chatEngine = chatEngine; this.chatEngine = chatEngine;
this.mediaEngine = mediaEngine; 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 }; 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 { function getOneShotTasks(): OneShotTasks {
if (!oneShotTasks) { if (!oneShotTasks) {
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine); oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine, engineBundle!.postEngine);
} }
return oneShotTasks; 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 ============ // ============ A2UI Actions ============
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => { 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 // Post Language Detection
detectPostLanguage: (title: string, content: string) => ipcRenderer.invoke('chat:detectPostLanguage', title, content), 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 // Event listeners for streaming/progress
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => { onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data); const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);

View File

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

View File

@@ -83,6 +83,8 @@
"render.month.12": "Dezember", "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.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.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.name": "Beiträge für semantische Ähnlichkeit indexieren",
"task.embeddingIndex.loading": "Modell wird geladen…", "task.embeddingIndex.loading": "Modell wird geladen…",
"task.embeddingIndex.indexing": "Indexierung: {indexed}/{total}", "task.embeddingIndex.indexing": "Indexierung: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "December", "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.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.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.name": "Index posts for Semantic Similarity",
"task.embeddingIndex.loading": "Loading model…", "task.embeddingIndex.loading": "Loading model…",
"task.embeddingIndex.indexing": "Indexing: {indexed}/{total}", "task.embeddingIndex.indexing": "Indexing: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "diciembre", "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.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.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.name": "Indexar entradas para similitud semántica",
"task.embeddingIndex.loading": "Cargando modelo…", "task.embeddingIndex.loading": "Cargando modelo…",
"task.embeddingIndex.indexing": "Indexando: {indexed}/{total}", "task.embeddingIndex.indexing": "Indexando: {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "décembre", "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.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.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.name": "Indexer les articles pour la similarité sémantique",
"task.embeddingIndex.loading": "Chargement du modèle…", "task.embeddingIndex.loading": "Chargement du modèle…",
"task.embeddingIndex.indexing": "Indexation : {indexed}/{total}", "task.embeddingIndex.indexing": "Indexation : {indexed}/{total}",

View File

@@ -83,6 +83,8 @@
"render.month.12": "dicembre", "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.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.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.name": "Indicizza i post per la similarità semantica",
"task.embeddingIndex.loading": "Caricamento modello…", "task.embeddingIndex.loading": "Caricamento modello…",
"task.embeddingIndex.indexing": "Indicizzazione: {indexed}/{total}", "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.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.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.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'), 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: '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', name: 'SimilarPost',
description: 'A post with its semantic similarity score relative to a reference post.', 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 = { export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.12.0', version: '1.13.0',
generatedAt: '2026-03-05T00:00:00.000Z', generatedAt: '2026-03-07T00:00:00.000Z',
methods: METHODS_V1, methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1, dataStructures: DATA_STRUCTURES_V1,
}; };

View File

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

View File

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

View File

@@ -24,7 +24,8 @@ import { TemplatesView } from '../TemplatesView/TemplatesView';
import { DuplicatesView } from '../DuplicatesView/DuplicatesView'; import { DuplicatesView } from '../DuplicatesView/DuplicatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils'; import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal'; 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 { openEntityTab } from '../../navigation/tabPolicy';
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting'; import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor'; import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
@@ -67,6 +68,7 @@ const autoSaveManager = new AutoSaveManager({
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {}; const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
if ('title' in changes) update.title = changes.title as string; if ('title' in changes) update.title = changes.title as string;
if ('content' in changes) update.content = changes.content 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) { if ('tags' in changes) {
const tagsStr = changes.tags as string; const tagsStr = changes.tags as string;
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0); 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 [title, setTitle] = useState('');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [excerpt, setExcerpt] = useState('');
const [author, setAuthor] = useState(''); const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']); const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
@@ -209,11 +212,21 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [showPostSearch, setShowPostSearch] = useState(false); const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false); const [showMediaSearch, setShowMediaSearch] = useState(false);
const [metadataExpanded, setMetadataExpanded] = useState(true); const [metadataExpanded, setMetadataExpanded] = useState(true);
const [excerptExpanded, setExcerptExpanded] = useState(false);
const editorRef = useRef<unknown>(null); const editorRef = useRef<unknown>(null);
// Token incremented to signal Monaco that it should re-read its defaultValue. // Token incremented to signal Monaco that it should re-read its defaultValue.
// This is used instead of controlled `value` to avoid cursor-reset races. // This is used instead of controlled `value` to avoid cursor-reset races.
const [monacoResetToken, setMonacoResetToken] = useState(0); 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); const isDirty = checkIsDirty(postId);
// Listen for auto-save events to keep local post state in sync // 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) { if (post && !isInitialized) {
setTitle(post.title); setTitle(post.title);
setContent(post.content); setContent(post.content);
setExcerpt(post.excerpt || '');
setAuthor(post.author || ''); setAuthor(post.author || '');
setTags(post.tags); setTags(post.tags);
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']); 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) // Short-circuit: check cheap comparisons first (content changes on every keystroke)
const contentChanged = content !== post.content; const contentChanged = content !== post.content;
const titleChanged = title !== post.title; const titleChanged = title !== post.title;
const excerptChanged = excerpt !== (post.excerpt || '');
const authorChanged = author !== (post.author || ''); const authorChanged = author !== (post.author || '');
const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || ''); const templateSlugChanged = templateSlug !== ((post as PostData & { templateSlug?: string }).templateSlug || '');
const languageChanged = postLanguage !== (post.language || ''); 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(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()) ||
JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.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, { autoSaveManager.notifyChange(postId, {
title, title,
content, content,
excerpt,
author, author,
tags: tags.join(', '), tags: tags.join(', '),
categories: selectedCategories, categories: selectedCategories,
@@ -372,7 +388,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
} else { } else {
markClean(postId); 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 // Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => { const handleEditorModeChange = (mode: EditorMode) => {
@@ -391,6 +407,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const updated = await window.electronAPI?.posts.update(postId, { const updated = await window.electronAPI?.posts.update(postId, {
title, title,
content, content,
excerpt: excerpt || undefined,
author: author || undefined, author: author || undefined,
language: postLanguage || undefined, language: postLanguage || undefined,
tags, tags,
@@ -434,6 +451,100 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
setIsDetectingLanguage(false); setIsDetectingLanguage(false);
} }
}, [title, content, isDetectingLanguage, tr]); }, [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 () => { const handlePublish = async () => {
await handleSave(); await handleSave();
try { try {
@@ -743,6 +854,31 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
{post.status} {post.status}
</span> </span>
{isSaving && <span className="auto-save-indicator">{tr('editor.saving')}</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' && ( {post.status === 'draft' && (
<button <button
onClick={handlePublish} onClick={handlePublish}
@@ -881,6 +1017,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-body">
<div className="editor-toolbar"> <div className="editor-toolbar">
<div className="editor-toolbar-left"> <div className="editor-toolbar-left">
@@ -1038,6 +1195,19 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowMediaSearch(false)} 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> </div>
); );
}; };
@@ -1067,7 +1237,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
// AI suggestions modal state // AI suggestions modal state
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false); const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
const [isAnalyzing, setIsAnalyzing] = 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); const [aiError, setAIError] = useState<string | undefined>(undefined);
// Load project language setting // Load project language setting
@@ -1100,18 +1270,18 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
setShowQuickActions(false); setShowQuickActions(false);
setShowAISuggestionsModal(true); setShowAISuggestionsModal(true);
setIsAnalyzing(true); setIsAnalyzing(true);
setAISuggestions(null); setAISuggestionFields([]);
setAIError(undefined); setAIError(undefined);
try { try {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage); const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) { if (result?.success) {
setAISuggestions({ setAISuggestionFields([
title: result.title, { key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
alt: result.alt, { key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
caption: result.caption, { key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
}); ]);
} else { } else {
setAIError(result?.error || tr('editor.media.error.analyzeImage')); setAIError(result?.error || tr('editor.media.error.analyzeImage'));
} }
@@ -1124,7 +1294,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
}; };
// Handle applying AI suggestions // Handle applying AI suggestions
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => { const handleApplyAISuggestions = (values: Record<string, string>) => {
if (values.title) setTitle(values.title); if (values.title) setTitle(values.title);
if (values.alt) setAlt(values.alt); if (values.alt) setAlt(values.alt);
if (values.caption) setCaption(values.caption); if (values.caption) setCaption(values.caption);
@@ -1551,8 +1721,10 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
<AISuggestionsModal <AISuggestionsModal
isOpen={showAISuggestionsModal} isOpen={showAISuggestionsModal}
isLoading={isAnalyzing} isLoading={isAnalyzing}
suggestions={aiSuggestions} fields={aiSuggestionFields}
currentValues={{ title, alt, caption }} modalTitle={tr('aiSuggestions.title')}
loadingText={tr('aiSuggestions.analyzing')}
emptyText={tr('aiSuggestions.empty')}
error={aiError} error={aiError}
onConfirm={handleApplyAISuggestions} onConfirm={handleApplyAISuggestions}
onClose={handleCloseAISuggestionsModal} onClose={handleCloseAISuggestionsModal}

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.", "aiSuggestions.empty": "Für dieses Bild wurden keine Vorschläge erstellt.",
"aiSuggestions.wait": "Bitte warten...", "aiSuggestions.wait": "Bitte warten...",
"aiSuggestions.applySelected": "Ausgewählte übernehmen", "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.link": "Link einfügen",
"insert.title.image": "Bild einfügen", "insert.title.image": "Bild einfügen",
"insert.tab.linkInternal": "Mit Beitrag verlinken", "insert.tab.linkInternal": "Mit Beitrag verlinken",
@@ -545,6 +551,7 @@
"editor.field.tags": "Schlagwörter", "editor.field.tags": "Schlagwörter",
"editor.field.author": "Autor", "editor.field.author": "Autor",
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.excerpt": "Auszug",
"editor.field.categories": "Kategorien", "editor.field.categories": "Kategorien",
"editor.field.content": "Inhalt", "editor.field.content": "Inhalt",
"editor.field.template": "Vorlage", "editor.field.template": "Vorlage",
@@ -558,6 +565,7 @@
"language.es": "Spanisch", "language.es": "Spanisch",
"editor.placeholder.tags": "Tags hinzufügen...", "editor.placeholder.tags": "Tags hinzufügen...",
"editor.placeholder.author": "Autorenname", "editor.placeholder.author": "Autorenname",
"editor.placeholder.excerpt": "Optionale Zusammenfassung für Listen und Vorschauen",
"editor.placeholder.categories": "Kategorien hinzufügen...", "editor.placeholder.categories": "Kategorien hinzufügen...",
"editor.placeholder.startWriting": "Mit dem Schreiben beginnen...", "editor.placeholder.startWriting": "Mit dem Schreiben beginnen...",
"editor.mode.visual": "Visuell", "editor.mode.visual": "Visuell",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Beitragsvorschau", "editor.previewFrameTitle": "Beitragsvorschau",
"editor.previewLoading": "Vorschau wird geladen...", "editor.previewLoading": "Vorschau wird geladen...",
"editor.metadata.toggle": "Metadaten", "editor.metadata.toggle": "Metadaten",
"editor.excerpt.toggle": "Auszug",
"editor.footer.created": "Erstellt", "editor.footer.created": "Erstellt",
"editor.footer.updated": "Aktualisiert", "editor.footer.updated": "Aktualisiert",
"editor.footer.published": "Veröffentlicht", "editor.footer.published": "Veröffentlicht",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Schnellaktionen", "editor.media.quickActions.button": "⚡ Schnellaktionen",
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen", "editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor", "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.detectLanguageDescription": "Sprache mit KI erkennen",
"editor.post.quickActions.detecting": "Erkennung…", "editor.post.quickActions.detecting": "Erkennung…",
"editor.post.quickActions.languageDetected": "Sprache erkannt", "editor.post.quickActions.languageDetected": "Sprache erkannt",
"editor.post.quickActions.detectLanguageFailed": "Spracherkennung fehlgeschlagen", "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.replaceFile": "Datei ersetzen",
"editor.media.field.fileName": "Dateiname", "editor.media.field.fileName": "Dateiname",
"editor.media.field.type": "Typ", "editor.media.field.type": "Typ",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No suggestions were generated for this image.", "aiSuggestions.empty": "No suggestions were generated for this image.",
"aiSuggestions.wait": "Please wait...", "aiSuggestions.wait": "Please wait...",
"aiSuggestions.applySelected": "Apply Selected", "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.link": "Insert Link",
"insert.title.image": "Insert Image", "insert.title.image": "Insert Image",
"insert.tab.linkInternal": "Link to Post", "insert.tab.linkInternal": "Link to Post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tags", "editor.field.tags": "Tags",
"editor.field.author": "Author", "editor.field.author": "Author",
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.excerpt": "Excerpt",
"editor.field.categories": "Categories", "editor.field.categories": "Categories",
"editor.field.content": "Content", "editor.field.content": "Content",
"editor.field.template": "Template", "editor.field.template": "Template",
@@ -558,6 +565,7 @@
"language.es": "Spanish", "language.es": "Spanish",
"editor.placeholder.tags": "Add tags...", "editor.placeholder.tags": "Add tags...",
"editor.placeholder.author": "Author name", "editor.placeholder.author": "Author name",
"editor.placeholder.excerpt": "Optional summary for lists and previews",
"editor.placeholder.categories": "Add categories...", "editor.placeholder.categories": "Add categories...",
"editor.placeholder.startWriting": "Start writing...", "editor.placeholder.startWriting": "Start writing...",
"editor.mode.visual": "Visual", "editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Post preview", "editor.previewFrameTitle": "Post preview",
"editor.previewLoading": "Loading preview...", "editor.previewLoading": "Loading preview...",
"editor.metadata.toggle": "Metadata", "editor.metadata.toggle": "Metadata",
"editor.excerpt.toggle": "Excerpt",
"editor.footer.created": "Created", "editor.footer.created": "Created",
"editor.footer.updated": "Updated", "editor.footer.updated": "Updated",
"editor.footer.published": "Published", "editor.footer.published": "Published",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "⚡ Quick Actions", "editor.media.quickActions.button": "⚡ Quick Actions",
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption", "editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata", "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.detectLanguageDescription": "Detect language using AI",
"editor.post.quickActions.detecting": "Detecting…", "editor.post.quickActions.detecting": "Detecting…",
"editor.post.quickActions.languageDetected": "Language detected", "editor.post.quickActions.languageDetected": "Language detected",
"editor.post.quickActions.detectLanguageFailed": "Language detection failed", "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.replaceFile": "Replace File",
"editor.media.field.fileName": "File Name", "editor.media.field.fileName": "File Name",
"editor.media.field.type": "Type", "editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "No se generaron sugerencias para esta imagen.", "aiSuggestions.empty": "No se generaron sugerencias para esta imagen.",
"aiSuggestions.wait": "Por favor espera...", "aiSuggestions.wait": "Por favor espera...",
"aiSuggestions.applySelected": "Aplicar seleccionados", "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.link": "Insertar enlace",
"insert.title.image": "Insertar imagen", "insert.title.image": "Insertar imagen",
"insert.tab.linkInternal": "Enlazar a entrada", "insert.tab.linkInternal": "Enlazar a entrada",
@@ -545,6 +551,7 @@
"editor.field.tags": "Etiquetas", "editor.field.tags": "Etiquetas",
"editor.field.author": "Autor", "editor.field.author": "Autor",
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.excerpt": "Extracto",
"editor.field.categories": "Categorías", "editor.field.categories": "Categorías",
"editor.field.content": "Contenido", "editor.field.content": "Contenido",
"editor.field.template": "Plantilla", "editor.field.template": "Plantilla",
@@ -558,6 +565,7 @@
"language.es": "Español", "language.es": "Español",
"editor.placeholder.tags": "Agregar etiquetas...", "editor.placeholder.tags": "Agregar etiquetas...",
"editor.placeholder.author": "Nombre del autor", "editor.placeholder.author": "Nombre del autor",
"editor.placeholder.excerpt": "Resumen opcional para listas y vistas previas",
"editor.placeholder.categories": "Agregar categorías...", "editor.placeholder.categories": "Agregar categorías...",
"editor.placeholder.startWriting": "Empieza a escribir...", "editor.placeholder.startWriting": "Empieza a escribir...",
"editor.mode.visual": "Visual", "editor.mode.visual": "Visual",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Vista previa de la entrada", "editor.previewFrameTitle": "Vista previa de la entrada",
"editor.previewLoading": "Cargando vista previa...", "editor.previewLoading": "Cargando vista previa...",
"editor.metadata.toggle": "Metadatos", "editor.metadata.toggle": "Metadatos",
"editor.excerpt.toggle": "Extracto",
"editor.footer.created": "Creado", "editor.footer.created": "Creado",
"editor.footer.updated": "Actualizado", "editor.footer.updated": "Actualizado",
"editor.footer.published": "Publicado", "editor.footer.published": "Publicado",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizar con IA", "editor.media.quickActions.button": "✨ Analizar con IA",
"editor.media.quickActions.aiTitle": "Título sugerido por 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.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.detectLanguageDescription": "Detectar idioma con IA",
"editor.post.quickActions.detecting": "Detectando…", "editor.post.quickActions.detecting": "Detectando…",
"editor.post.quickActions.languageDetected": "Idioma detectado", "editor.post.quickActions.languageDetected": "Idioma detectado",
"editor.post.quickActions.detectLanguageFailed": "Error al detectar el idioma", "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.replaceFile": "Reemplazar archivo",
"editor.media.field.fileName": "Nombre de archivo", "editor.media.field.fileName": "Nombre de archivo",
"editor.media.field.type": "Tipo", "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.empty": "Aucune suggestion na été générée pour cette image.",
"aiSuggestions.wait": "Veuillez patienter...", "aiSuggestions.wait": "Veuillez patienter...",
"aiSuggestions.applySelected": "Appliquer la sélection", "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.link": "Insérer un lien",
"insert.title.image": "Insérer une image", "insert.title.image": "Insérer une image",
"insert.tab.linkInternal": "Lier à un article", "insert.tab.linkInternal": "Lier à un article",
@@ -545,6 +551,7 @@
"editor.field.tags": "Étiquettes", "editor.field.tags": "Étiquettes",
"editor.field.author": "Auteur", "editor.field.author": "Auteur",
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.excerpt": "Extrait",
"editor.field.categories": "Catégories", "editor.field.categories": "Catégories",
"editor.field.content": "Contenu", "editor.field.content": "Contenu",
"editor.field.template": "Modèle", "editor.field.template": "Modèle",
@@ -558,6 +565,7 @@
"language.es": "Espagnol", "language.es": "Espagnol",
"editor.placeholder.tags": "Ajouter des étiquettes...", "editor.placeholder.tags": "Ajouter des étiquettes...",
"editor.placeholder.author": "Nom de lauteur", "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.categories": "Ajouter des catégories...",
"editor.placeholder.startWriting": "Commencez à écrire...", "editor.placeholder.startWriting": "Commencez à écrire...",
"editor.mode.visual": "Visuel", "editor.mode.visual": "Visuel",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Aperçu de larticle", "editor.previewFrameTitle": "Aperçu de larticle",
"editor.previewLoading": "Chargement de l'aperçu...", "editor.previewLoading": "Chargement de l'aperçu...",
"editor.metadata.toggle": "Métadonnées", "editor.metadata.toggle": "Métadonnées",
"editor.excerpt.toggle": "Extrait",
"editor.footer.created": "Créé", "editor.footer.created": "Créé",
"editor.footer.updated": "Mis à jour", "editor.footer.updated": "Mis à jour",
"editor.footer.published": "Publié", "editor.footer.published": "Publié",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analyser avec lIA", "editor.media.quickActions.button": "✨ Analyser avec lIA",
"editor.media.quickActions.aiTitle": "Titre suggéré par 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.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.detectLanguageDescription": "Détecter la langue avec l'IA",
"editor.post.quickActions.detecting": "Détection…", "editor.post.quickActions.detecting": "Détection…",
"editor.post.quickActions.languageDetected": "Langue détectée", "editor.post.quickActions.languageDetected": "Langue détectée",
"editor.post.quickActions.detectLanguageFailed": "Échec de la détection de la langue", "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.replaceFile": "Remplacer le fichier",
"editor.media.field.fileName": "Nom du fichier", "editor.media.field.fileName": "Nom du fichier",
"editor.media.field.type": "Type", "editor.media.field.type": "Type",

View File

@@ -229,6 +229,12 @@
"aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.", "aiSuggestions.empty": "Nessun suggerimento è stato generato per questa immagine.",
"aiSuggestions.wait": "Attendere...", "aiSuggestions.wait": "Attendere...",
"aiSuggestions.applySelected": "Applica selezionati", "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.link": "Inserisci link",
"insert.title.image": "Inserisci immagine", "insert.title.image": "Inserisci immagine",
"insert.tab.linkInternal": "Collega al post", "insert.tab.linkInternal": "Collega al post",
@@ -545,6 +551,7 @@
"editor.field.tags": "Tag", "editor.field.tags": "Tag",
"editor.field.author": "Autore", "editor.field.author": "Autore",
"editor.field.slug": "Slug", "editor.field.slug": "Slug",
"editor.field.excerpt": "Estratto",
"editor.field.categories": "Categorie", "editor.field.categories": "Categorie",
"editor.field.content": "Contenuto", "editor.field.content": "Contenuto",
"editor.field.template": "Modello", "editor.field.template": "Modello",
@@ -558,6 +565,7 @@
"language.es": "Spagnolo", "language.es": "Spagnolo",
"editor.placeholder.tags": "Aggiungi tag...", "editor.placeholder.tags": "Aggiungi tag...",
"editor.placeholder.author": "Nome autore", "editor.placeholder.author": "Nome autore",
"editor.placeholder.excerpt": "Riassunto facoltativo per elenchi e anteprime",
"editor.placeholder.categories": "Aggiungi categorie...", "editor.placeholder.categories": "Aggiungi categorie...",
"editor.placeholder.startWriting": "Inizia a scrivere...", "editor.placeholder.startWriting": "Inizia a scrivere...",
"editor.mode.visual": "Visuale", "editor.mode.visual": "Visuale",
@@ -570,6 +578,7 @@
"editor.previewFrameTitle": "Anteprima post", "editor.previewFrameTitle": "Anteprima post",
"editor.previewLoading": "Caricamento anteprima...", "editor.previewLoading": "Caricamento anteprima...",
"editor.metadata.toggle": "Metadati", "editor.metadata.toggle": "Metadati",
"editor.excerpt.toggle": "Estratto",
"editor.footer.created": "Creato", "editor.footer.created": "Creato",
"editor.footer.updated": "Aggiornato", "editor.footer.updated": "Aggiornato",
"editor.footer.published": "Pubblicato", "editor.footer.published": "Pubblicato",
@@ -918,10 +927,18 @@
"editor.media.quickActions.button": "✨ Analizza con IA", "editor.media.quickActions.button": "✨ Analizza con IA",
"editor.media.quickActions.aiTitle": "Titolo suggerito dallIA", "editor.media.quickActions.aiTitle": "Titolo suggerito dallIA",
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.", "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.detectLanguageDescription": "Rileva la lingua con l'IA",
"editor.post.quickActions.detecting": "Rilevamento…", "editor.post.quickActions.detecting": "Rilevamento…",
"editor.post.quickActions.languageDetected": "Lingua rilevata", "editor.post.quickActions.languageDetected": "Lingua rilevata",
"editor.post.quickActions.detectLanguageFailed": "Rilevamento lingua non riuscito", "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.replaceFile": "Sostituisci file",
"editor.media.field.fileName": "Nome file", "editor.media.field.fileName": "Nome file",
"editor.media.field.type": "Tipo", "editor.media.field.type": "Tipo",

View File

@@ -183,4 +183,56 @@ describe('GenerationSitemapFeedService', () => {
expect(result.atomXml).toContain('xml:lang="en"'); expect(result.atomXml).toContain('xml:lang="en"');
expect(result.atomXml).toContain('xml:lang="de"'); expect(result.atomXml).toContain('xml:lang="de"');
}); });
it('uses excerpt instead of full body in feed entry content when excerpt is available', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'excerpt-post',
title: 'Excerpt Post',
excerpt: 'Short feed summary.',
content: '# Excerpt Post\n\nVery long body that should not appear in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('<content:encoded><![CDATA[<p>Short feed summary.</p>]]></content:encoded>');
expect(result.rssXml).not.toContain('Very long body that should not appear in feed content.');
expect(result.atomXml).toContain('<content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Short feed summary.</p></div></content>');
expect(result.atomXml).not.toContain('Very long body that should not appear in feed content.');
});
it('falls back to the post body in feed entry content when excerpt is missing', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'body-post',
title: 'Body Post',
content: '# Body Post\n\nBody paragraph used in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('Body paragraph used in feed content.');
expect(result.atomXml).toContain('Body paragraph used in feed content.');
});
}); });

View File

@@ -1028,6 +1028,40 @@ Original content`);
expect(result?.slug).toBe('new-title'); expect(result?.slug).toBe('new-title');
}); });
it('should honor explicit slug when title and slug both change on a never-published draft', async () => {
const created = await postEngine.createPost({ title: 'Original Title' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: null,
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, {
title: 'New Title',
slug: 'custom-ai-slug',
});
expect(result).not.toBeNull();
expect(result?.slug).toBe('custom-ai-slug');
});
it('should NOT auto-update slug when title changes on a previously published post', async () => { it('should NOT auto-update slug when title changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' }); const created = await postEngine.createPost({ title: 'Published Post' });
@@ -1059,6 +1093,37 @@ Original content`);
expect(result?.slug).toBe('published-post'); // slug preserved expect(result?.slug).toBe('published-post'); // slug preserved
}); });
it('should ignore explicit slug changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: new Date('2025-01-01'),
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, { slug: 'new-slug' });
expect(result).not.toBeNull();
expect(result?.slug).toBe('published-post');
});
it('should allow empty title and use untitled as slug base', async () => { it('should allow empty title and use untitled as slug base', async () => {
const created = await postEngine.createPost({ title: '' }); const created = await postEngine.createPost({ title: '' });
expect(created.title).toBe(''); expect(created.title).toBe('');

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the AI SDK generateText before importing OneShotTasks
vi.mock('ai', () => ({
generateText: vi.fn(),
}));
// Mock i18n
vi.mock('../../../src/main/shared/i18n', () => ({
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
translateRender: vi.fn((_lang: string, key: string) => {
const prompts: Record<string, string> = {
'ai.postAnalysis.system': 'You are a blog editor assistant. Analyze the blog post and suggest improvements. Return JSON with "title", "excerpt", "slug". Respond in en.',
'ai.postAnalysis.user': 'Analyze this blog post.',
};
return prompts[key] || key;
}),
}));
import { OneShotTasks, type PostAnalysisResult } from '../../../src/main/engine/ai/tasks';
import { generateText } from 'ai';
const mockGenerateText = vi.mocked(generateText);
function createMockDeps() {
const chatEngine = {
getSetting: vi.fn().mockResolvedValue(null),
} as any;
const providers = {
detectModelProvider: vi.fn().mockReturnValue('opencode'),
isProviderKeySet: vi.fn().mockReturnValue(true),
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
getMistralKey: vi.fn().mockReturnValue(null),
resolveModel: vi.fn().mockReturnValue('mock-model'),
isOfflineMode: vi.fn().mockReturnValue(false),
isOllamaModel: vi.fn().mockReturnValue(false),
isLmstudioModel: vi.fn().mockReturnValue(false),
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
} as any;
const mediaEngine = {} as any;
const postEngine = {
getPost: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
}
describe('OneShotTasks.analyzePost', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('returns title, excerpt, and slug from AI response', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
slug: 'my-post',
excerpt: '',
content: 'This is the content of my blog post about testing.',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "Better Title", "excerpt": "A concise summary of the post.", "slug": "better-title"}',
} as any);
const result: PostAnalysisResult = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.title).toBe('Better Title');
expect(result.excerpt).toBe('A concise summary of the post.');
expect(result.slug).toBe('better-title');
expect(deps.postEngine.getPost).toHaveBeenCalledWith('post-1');
});
it('returns error when post is not found', async () => {
deps.postEngine.getPost.mockResolvedValue(null);
const result = await tasks.analyzePost('nonexistent', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post not found');
});
it('returns error when post has no content', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: '',
slug: 'post-1',
content: '',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post has no content to analyze');
});
it('returns error when no API key is configured', async () => {
deps.providers.getOpencodeKey.mockReturnValue(null);
deps.providers.getMistralKey.mockReturnValue(null);
deps.providers.isProviderKeySet.mockReturnValue(false);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content here',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
it('sanitizes slug to lowercase with hyphens only', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
slug: 'test',
content: 'Content here',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "New Title", "excerpt": "Summary text.", "slug": "Some Weird Slug!"}',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.slug).toMatch(/^[a-z0-9-]+$/);
});
it('handles AI response parse errors gracefully', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: 'not valid json at all',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid response format from AI');
});
it('uses title model when configured', async () => {
deps.chatEngine.getSetting.mockResolvedValue('custom-model-id');
deps.providers.detectModelProvider.mockReturnValue('opencode');
deps.providers.isProviderKeySet.mockReturnValue(true);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "T", "excerpt": "E", "slug": "t"}',
} as any);
await tasks.analyzePost('post-1', 'en');
expect(deps.chatEngine.getSetting).toHaveBeenCalledWith('chat_title_model');
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
});
});

View File

@@ -1,19 +1,13 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { AISuggestionsModal, type AISuggestions } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal'; import { AISuggestionsModal, type SuggestionField } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal';
const currentValues = { const mediaFields: SuggestionField[] = [
title: 'Existing title', { key: 'title', label: 'Title', currentValue: 'Existing title', suggestedValue: 'Suggested title' },
alt: 'Existing alt', { key: 'alt', label: 'Alt Text', currentValue: 'Existing alt', suggestedValue: 'Suggested alt' },
caption: '', { key: 'caption', label: 'Caption', currentValue: '', suggestedValue: 'Suggested caption' },
}; ];
const baseSuggestions: AISuggestions = {
title: 'Suggested title',
alt: 'Suggested alt',
caption: 'Suggested caption',
};
describe('AISuggestionsModal', () => { describe('AISuggestionsModal', () => {
it('shows suggested fields and applies only selected values', () => { it('shows suggested fields and applies only selected values', () => {
@@ -23,8 +17,10 @@ describe('AISuggestionsModal', () => {
<AISuggestionsModal <AISuggestionsModal
isOpen isOpen
isLoading={false} isLoading={false}
suggestions={baseSuggestions} fields={mediaFields}
currentValues={currentValues} modalTitle="AI Image Analysis"
loadingText="Analyzing image..."
emptyText="No suggestions."
onConfirm={onConfirm} onConfirm={onConfirm}
onClose={vi.fn()} onClose={vi.fn()}
/> />
@@ -37,8 +33,10 @@ describe('AISuggestionsModal', () => {
const applyButton = screen.getByRole('button', { name: 'Apply Selected' }); const applyButton = screen.getByRole('button', { name: 'Apply Selected' });
const [titleCheckbox, altCheckbox, captionCheckbox] = screen.getAllByRole('checkbox') as HTMLInputElement[]; const [titleCheckbox, altCheckbox, captionCheckbox] = screen.getAllByRole('checkbox') as HTMLInputElement[];
// Fields with existing values should be unchecked
expect(titleCheckbox.checked).toBe(false); expect(titleCheckbox.checked).toBe(false);
expect(altCheckbox.checked).toBe(false); expect(altCheckbox.checked).toBe(false);
// Field with empty current value should be checked
expect(captionCheckbox.checked).toBe(true); expect(captionCheckbox.checked).toBe(true);
expect(applyButton).not.toBeDisabled(); expect(applyButton).not.toBeDisabled();
@@ -57,18 +55,78 @@ describe('AISuggestionsModal', () => {
}); });
it('hides apply button when no suggestions are available', () => { it('hides apply button when no suggestions are available', () => {
const emptyFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: '', suggestedValue: undefined },
];
render( render(
<AISuggestionsModal <AISuggestionsModal
isOpen isOpen
isLoading={false} isLoading={false}
suggestions={{}} fields={emptyFields}
currentValues={{ title: '', alt: '', caption: '' }} modalTitle="AI Analysis"
loadingText="Analyzing..."
emptyText="No suggestions were generated."
onConfirm={vi.fn()} onConfirm={vi.fn()}
onClose={vi.fn()} onClose={vi.fn()}
/> />
); );
expect(screen.queryByRole('button', { name: 'Apply Selected' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Apply Selected' })).toBeNull();
expect(screen.getByText('No suggestions were generated for this image.')).toBeTruthy(); expect(screen.getByText('No suggestions were generated.')).toBeTruthy();
});
it('shows custom modal title and loading text', () => {
render(
<AISuggestionsModal
isOpen
isLoading={true}
fields={[]}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('AI Post Analysis')).toBeTruthy();
expect(screen.getByText('Analyzing post…')).toBeTruthy();
});
it('works with post analysis fields (title, excerpt, slug)', () => {
const postFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: 'My Post', suggestedValue: 'Better Title' },
{ key: 'excerpt', label: 'Summary / Excerpt', currentValue: '', suggestedValue: 'A concise summary.' },
{ key: 'slug', label: 'Slug', currentValue: 'my-post', suggestedValue: 'better-title' },
];
const onConfirm = vi.fn();
render(
<AISuggestionsModal
isOpen
isLoading={false}
fields={postFields}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={onConfirm}
onClose={vi.fn()}
/>
);
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// title has value → unchecked; excerpt empty → checked; slug has value → unchecked
expect(checkboxes[0].checked).toBe(false); // title
expect(checkboxes[1].checked).toBe(true); // excerpt
expect(checkboxes[2].checked).toBe(false); // slug
// Apply only the excerpt (pre-selected)
fireEvent.click(screen.getByRole('button', { name: 'Apply Selected' }));
expect(onConfirm).toHaveBeenCalledWith({
excerpt: 'A concise summary.',
});
}); });
}); });

View File

@@ -229,4 +229,37 @@ describe('Editor metadata collapse', () => {
}); });
expect(container.querySelector('.editor-header-row')).toBeNull(); expect(container.querySelector('.editor-header-row')).toBeNull();
}); });
it('keeps excerpt panel collapsed by default and toggles it independently', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' }));
const { container } = render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(container.querySelector('.editor-header-row')).not.toBeNull();
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
const excerptToggle = Array.from(container.querySelectorAll('.metadata-toggle')).find((node) =>
node.textContent?.includes('Excerpt')
);
expect(excerptToggle).not.toBeNull();
expect(excerptToggle?.classList.contains('expanded')).toBe(false);
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).not.toBeNull();
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
});
}); });

View File

@@ -0,0 +1,253 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act, fireEvent, screen } from '@testing-library/react';
let lastSuggestionFields: Array<{ key: string; label: string; currentValue: string; suggestedValue?: string; disabled?: boolean; warning?: string }> = [];
vi.mock('@monaco-editor/react', () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock('@milkdown/kit/core', () => {
const makeChain = () => {
const chain = {
config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => {
callback({
set: () => {},
get: () => ({ markdownUpdated: () => {} }),
});
return chain;
},
use: () => chain,
};
return chain;
};
return {
Editor: { make: makeChain },
defaultValueCtx: Symbol('defaultValueCtx'),
editorViewCtx: Symbol('editorViewCtx'),
rootCtx: Symbol('rootCtx'),
remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'),
remarkPluginsCtx: Symbol('remarkPluginsCtx'),
};
});
vi.mock('@milkdown/kit/preset/commonmark', () => ({
commonmark: {},
toggleStrongCommand: { key: 'toggleStrong' },
toggleEmphasisCommand: { key: 'toggleEmphasis' },
wrapInBlockquoteCommand: { key: 'wrapInBlockquote' },
wrapInBulletListCommand: { key: 'wrapInBulletList' },
wrapInOrderedListCommand: { key: 'wrapInOrderedList' },
insertHrCommand: { key: 'insertHr' },
toggleInlineCodeCommand: { key: 'toggleInlineCode' },
insertImageCommand: { key: 'insertImage' },
toggleLinkCommand: { key: 'toggleLink' },
}));
vi.mock('@milkdown/kit/preset/gfm', () => ({
gfm: {},
toggleStrikethroughCommand: { key: 'toggleStrike' },
}));
vi.mock('@milkdown/kit/plugin/history', () => ({
history: {},
undoCommand: { key: 'undo' },
redoCommand: { key: 'redo' },
}));
vi.mock('@milkdown/kit/plugin/listener', () => ({
listener: {},
listenerCtx: Symbol('listenerCtx'),
}));
vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} }));
vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} }));
vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} }));
vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} }));
vi.mock('@milkdown/kit/utils', () => ({
$node: () => ({}),
$inputRule: () => ({}),
$remark: () => ({}),
$prose: () => ({}),
replaceAll: () => () => {},
callCommand: () => () => {},
}));
vi.mock('@milkdown/react', () => ({
Milkdown: () => <div data-testid="milkdown" />,
MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useInstance: () => [false, () => ({ action: (action: unknown) => {
if (typeof action === 'function') {
action({ get: () => ({}) });
}
} })] as const,
useEditor: (factory: (root: Node) => unknown) => {
factory(document.createElement('div'));
},
}));
vi.mock('../../../src/renderer/components/Lightbox', () => ({
Lightbox: () => null,
useMarkdownImages: () => [],
}));
vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null }));
vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null }));
vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null }));
vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null }));
vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null }));
vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null }));
vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null }));
vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null }));
vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null }));
vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null }));
vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null }));
vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null }));
vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({
AISuggestionsModal: ({ isOpen, fields, onConfirm }: { isOpen: boolean; fields: typeof lastSuggestionFields; onConfirm: (values: Record<string, string>) => void }) => {
if (!isOpen) return null;
lastSuggestionFields = fields;
return (
<div data-testid="ai-suggestions-modal">
{fields.map((field) => (
<div key={field.key} data-testid={`field-${field.key}`} data-disabled={field.disabled ? 'true' : 'false'}>
{field.label}
</div>
))}
<button onClick={() => onConfirm({ title: 'Better Title', slug: 'better-title' })}>apply-suggestions</button>
</div>
);
},
}));
vi.mock('../../../src/renderer/components/Toast', () => ({
showToast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
import { useAppStore } from '../../../src/renderer/store';
const createPost = (overrides: Record<string, unknown> = {}) => ({
id: 'post-1',
title: 'Test Post',
content: 'Some content',
excerpt: '',
slug: 'test-post',
status: 'draft' as const,
tags: [],
categories: ['article'],
featuredImage: null,
publishedAt: null,
createdAt: new Date('2026-02-16T12:00:00.000Z'),
updatedAt: new Date('2026-02-16T12:00:00.000Z'),
author: undefined,
metadata: {},
seoTitle: undefined,
seoDescription: undefined,
canonicalUrl: undefined,
projectId: 'project-1',
filePath: 'posts/test-post.md',
...overrides,
});
describe('Editor AI post suggestions', () => {
beforeEach(() => {
vi.clearAllMocks();
lastSuggestionFields = [];
const neverSettles = new Promise<never>(() => {});
(window as any).electronAPI ??= {};
(window as any).electronAPI.posts ??= {};
(window as any).electronAPI.meta ??= {};
(window as any).electronAPI.chat ??= {};
(window as any).electronAPI.templates ??= {};
(window as any).addEventListener = vi.fn();
(window as any).removeEventListener = vi.fn();
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/preview');
(window as any).electronAPI.posts.update = vi.fn().mockImplementation(async (_postId: string, payload: Record<string, string>) => ({
...createPost(),
...payload,
}));
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({ mainLanguage: 'en' });
(window as any).electronAPI.templates.getEnabledByKind = vi.fn().mockResolvedValue([]);
(window as any).electronAPI.chat.analyzePost = vi.fn().mockResolvedValue({
success: true,
title: 'Better Title',
excerpt: 'A concise summary.',
slug: 'better-title',
});
useAppStore.setState({
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
preferredEditorMode: 'wysiwyg',
posts: [],
media: [],
dirtyPosts: new Set<string>(),
isLoading: false,
});
});
it('passes a disabled slug suggestion for published posts', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({
status: 'published',
publishedAt: new Date('2026-02-16T12:00:00.000Z'),
}));
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
const slugField = lastSuggestionFields.find((field) => field.key === 'slug');
expect(slugField).toBeDefined();
expect(slugField?.disabled).toBe(true);
});
it('submits the AI slug for a never-published draft when applying suggestions', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'apply-suggestions' }));
});
expect((window as any).electronAPI.posts.update).toHaveBeenCalledWith(
'post-1',
expect.objectContaining({ title: 'Better Title', slug: 'better-title' })
);
});
});

View File

@@ -62,7 +62,7 @@ describe('pythonApiContractV1', () => {
it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => { it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => {
const methodNames = listPythonApiMethodNames(); const methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.')); const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage']); expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage', 'chat.analyzePost']);
}); });
it('documents chat.analyzeMediaImage contract with mediaId and language params', () => { it('documents chat.analyzeMediaImage contract with mediaId and language params', () => {
@@ -79,7 +79,7 @@ describe('pythonApiContractV1', () => {
it('contains semantic version metadata for compatibility checks', () => { it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.12.0', version: '1.13.0',
generatedAt: expect.any(String), generatedAt: expect.any(String),
}); });
}); });