feat: added field "title" and switched to it to free up caption for its normal use

This commit is contained in:
2026-02-15 09:09:48 +01:00
parent 4f71ac25bc
commit b5795867a8
20 changed files with 886 additions and 42 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `media` ADD `title` text;

View File

@@ -0,0 +1,759 @@
{
"version": "6",
"dialect": "sqlite",
"id": "26aa5345-b6d8-4426-a144-0199140a896a",
"prevId": "c9e34b7f-92a5-4549-99c9-e5a680004bfc",
"tables": {
"chat_conversations": {
"name": "chat_conversations",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"copilot_session_id": {
"name": "copilot_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chat_messages": {
"name": "chat_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_call_id": {
"name": "tool_call_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tool_calls": {
"name": "tool_calls",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"import_definitions": {
"name": "import_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"wxr_file_path": {
"name": "wxr_file_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uploads_folder_path": {
"name": "uploads_folder_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_analysis_result": {
"name": "last_analysis_result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"media": {
"name": "media",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"alt": {
"name": "alt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sidecar_path": {
"name": "sidecar_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_links": {
"name": "post_links",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_post_id": {
"name": "source_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_post_id": {
"name": "target_post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"link_text": {
"name": "link_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"post_media": {
"name": "post_media",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media_id": {
"name": "media_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"post_media_post_media_idx": {
"name": "post_media_post_media_idx",
"columns": [
"post_id",
"media_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"excerpt": {
"name": "excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"checksum": {
"name": "checksum",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_title": {
"name": "published_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_content": {
"name": "published_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_tags": {
"name": "published_tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_categories": {
"name": "published_categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_excerpt": {
"name": "published_excerpt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"posts_project_slug_idx": {
"name": "posts_project_slug_idx",
"columns": [
"project_id",
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"data_path": {
"name": "data_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_project_name_idx": {
"name": "tags_project_name_idx",
"columns": [
"project_id",
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1771088786493,
"tag": "0001_narrow_black_bolt",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1771141922712,
"tag": "0002_rainy_luckman",
"breakpoints": true
}
]
}

View File

@@ -54,6 +54,7 @@ export const media = sqliteTable('media', {
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
title: text('title'),
alt: text('alt'),
caption: text('caption'),
filePath: text('file_path').notNull(),

View File

@@ -313,7 +313,7 @@ Available Tools:
- list_media: List media files with optional MIME type filtering.
- view_image: View an image to analyze its visual content. Use this when you need to describe or analyze what an image looks like.
- update_post_metadata: Update a post's title, excerpt, tags, or categories.
- update_media_metadata: Update a media file's alt text, caption, or tags.
- update_media_metadata: Update a media file's title, alt text, caption, or tags.
- list_tags: List all tags with post counts.
- list_categories: List all categories with post counts.
- get_post_backlinks: Get posts that link TO a given post (backlinks). Use to discover what references a post.

View File

@@ -573,7 +573,7 @@ export class ImportExecutionEngine extends EventEmitter {
// Import the media file
const mediaEngine = getMediaEngine();
await mediaEngine.importMedia(sourcePath, {
caption: wxrMedia.title || undefined,
title: wxrMedia.title || undefined,
alt: wxrMedia.description || undefined,
mimeType: wxrMedia.mimeType,
tags: [],

View File

@@ -27,6 +27,7 @@ export interface MediaData {
size: number;
width?: number;
height?: number;
title?: string;
alt?: string;
caption?: string;
createdAt: Date;
@@ -42,6 +43,7 @@ export interface MediaMetadata {
size: number;
width?: number;
height?: number;
title?: string;
alt?: string;
caption?: string;
createdAt: string;
@@ -61,7 +63,7 @@ export interface MediaFilter {
export interface MediaSearchResult {
id: string;
originalName: string;
caption?: string;
title?: string;
mimeType: string;
createdAt: Date;
}
@@ -92,12 +94,13 @@ export class MediaEngine extends EventEmitter {
/**
* Update the FTS index for a media item.
* Stores stemmed content from original_name, alt, caption, and tags.
* Stores stemmed content from original_name, title, alt, caption, and tags.
*/
private async updateFTSIndex(item: {
id: string;
projectId: string;
originalName: string;
title?: string;
alt?: string;
caption?: string;
tags: string[];
@@ -111,6 +114,7 @@ export class MediaEngine extends EventEmitter {
// Combine all searchable fields and stem them
const allText = [
item.originalName,
item.title || '',
item.alt || '',
item.caption || '',
item.tags.join(' '),
@@ -300,6 +304,7 @@ export class MediaEngine extends EventEmitter {
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
title: mediaData.title,
alt: mediaData.alt,
caption: mediaData.caption,
createdAt: mediaData.createdAt.toISOString(),
@@ -319,6 +324,7 @@ export class MediaEngine extends EventEmitter {
if (metadata.width) lines.push(`width: ${metadata.width}`);
if (metadata.height) lines.push(`height: ${metadata.height}`);
if (metadata.title) lines.push(`title: "${metadata.title}"`);
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
@@ -385,6 +391,9 @@ export class MediaEngine extends EventEmitter {
case 'height':
metadata.height = parseInt(value, 10);
break;
case 'title':
metadata.title = value;
break;
case 'alt':
metadata.alt = value;
break;
@@ -492,6 +501,7 @@ export class MediaEngine extends EventEmitter {
size: sourceBuffer.length,
width,
height,
title: metadata?.title,
alt: metadata?.alt,
caption: metadata?.caption,
createdAt,
@@ -518,6 +528,7 @@ export class MediaEngine extends EventEmitter {
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
title: mediaData.title,
alt: mediaData.alt,
caption: mediaData.caption,
filePath: destPath,
@@ -535,6 +546,7 @@ export class MediaEngine extends EventEmitter {
id: mediaData.id,
projectId: this.currentProjectId,
originalName: mediaData.originalName,
title: mediaData.title,
alt: mediaData.alt,
caption: mediaData.caption,
tags: mediaData.tags,
@@ -566,6 +578,7 @@ export class MediaEngine extends EventEmitter {
await db.update(media)
.set({
title: updated.title,
alt: updated.alt,
caption: updated.caption,
updatedAt: updated.updatedAt,
@@ -578,6 +591,7 @@ export class MediaEngine extends EventEmitter {
id: updated.id,
projectId: this.currentProjectId,
originalName: updated.originalName,
title: updated.title,
alt: updated.alt,
caption: updated.caption,
tags: updated.tags,
@@ -641,6 +655,7 @@ export class MediaEngine extends EventEmitter {
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
title: dbMedia.title || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
@@ -666,6 +681,7 @@ export class MediaEngine extends EventEmitter {
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
title: dbMedia.title || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
@@ -726,6 +742,7 @@ export class MediaEngine extends EventEmitter {
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
title: dbMedia.title || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
@@ -770,7 +787,7 @@ export class MediaEngine extends EventEmitter {
searchResults.push({
id: item.id,
originalName: item.originalName,
caption: item.caption || undefined,
title: item.title || undefined,
mimeType: item.mimeType,
createdAt: item.createdAt,
});

View File

@@ -739,11 +739,12 @@ export class OpenCodeManager {
},
{
name: 'update_media_metadata',
description: 'Update metadata for a media file (alt text, caption, tags).',
description: 'Update metadata for a media file (title, alt text, caption, tags).',
input_schema: {
type: 'object',
properties: {
mediaId: { type: 'string', description: 'The unique ID of the media to update' },
title: { type: 'string', description: 'New title for display in lists and search results' },
alt: { type: 'string', description: 'New alt text for the image' },
caption: { type: 'string', description: 'New caption for the image' },
tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the media' },
@@ -926,7 +927,7 @@ export class OpenCodeManager {
id: media.id, filename: media.filename,
originalName: media.originalName, mimeType: media.mimeType,
size: media.size, width: media.width, height: media.height,
alt: media.alt, caption: media.caption, tags: media.tags,
title: media.title, alt: media.alt, caption: media.caption, tags: media.tags,
createdAt: media.createdAt, updatedAt: media.updatedAt,
},
};
@@ -945,7 +946,7 @@ export class OpenCodeManager {
media: mediaList.map(m => ({
id: m.id, filename: m.filename,
originalName: m.originalName, mimeType: m.mimeType,
alt: m.alt, tags: m.tags,
title: m.title, alt: m.alt, tags: m.tags,
})),
};
}
@@ -967,6 +968,7 @@ export class OpenCodeManager {
case 'update_media_metadata': {
const updates: Record<string, unknown> = {};
if (args.title !== undefined) updates.title = args.title;
if (args.alt !== undefined) updates.alt = args.alt;
if (args.caption !== undefined) updates.caption = args.caption;
if (args.tags !== undefined) updates.tags = args.tags;
@@ -1033,6 +1035,7 @@ export class OpenCodeManager {
originalName: mediaItem.originalName,
width: mediaItem.width,
height: mediaItem.height,
title: mediaItem.title,
alt: mediaItem.alt,
caption: mediaItem.caption,
size: size,
@@ -1080,6 +1083,7 @@ export class OpenCodeManager {
filename: link.media.filename,
originalName: link.media.originalName,
mimeType: link.media.mimeType,
title: link.media.title,
alt: link.media.alt,
caption: link.media.caption,
width: link.media.width,
@@ -1451,11 +1455,12 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
}
/**
* Analyze a media image and generate alt text and caption using AI
* Analyze a media image and generate title, alt text, and caption using AI
* This is a one-shot request that looks at the image and suggests metadata
*/
async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{
success: boolean;
title?: string;
alt?: string;
caption?: string;
error?: string;
@@ -1496,12 +1501,13 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
};
const languageName = languageNames[language] || language;
const systemPrompt = `Generate alt text and caption for this image in ${languageName}.
const systemPrompt = `Generate title, alt text, and caption for this image in ${languageName}.
TITLE: A short, descriptive title for display in lists and search results (3-8 words). Should identify the main subject.
ALT: Describe ONLY what is visually present in the image. Be factual, neutral, and concise (5-12 words max). No interpretations, emotions, or "Image of" prefix. Example: "Red bicycle leaning against white brick wall"
CAPTION: Short, engaging blog caption (5-20 words).
Respond with JSON only: {"alt": "...", "caption": "..."}`;
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
try {
// Using Claude Sonnet 4.5 for best image analysis
@@ -1570,6 +1576,7 @@ Respond with JSON only: {"alt": "...", "caption": "..."}`;
return {
success: true,
title: result.title || undefined,
alt: result.alt || undefined,
caption: result.caption || undefined,
};

View File

@@ -333,7 +333,7 @@ export function registerChatHandlers(): void {
// ============ Media Analysis ============
// Analyze a media image and generate alt text and caption
// Analyze a media image and generate title, alt text, and caption
ipcMain.handle('chat:analyzeMediaImage', async (_, mediaId: string, language?: string) => {
try {
const manager = getOpenCodeManager();

View File

@@ -1426,6 +1426,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const item = media.find(m => m.id === mediaId);
const [title, setTitle] = useState(item?.title || '');
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
@@ -1474,6 +1475,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
if (result?.success) {
if (result.title) setTitle(result.title);
if (result.alt) setAlt(result.alt);
if (result.caption) setCaption(result.caption);
showToast.success('AI analysis complete');
@@ -1581,6 +1583,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
useEffect(() => {
if (item) {
setTitle(item.title || '');
setAlt(item.alt || '');
setCaption(item.caption || '');
setTags(item.tags.join(', '));
@@ -1594,6 +1597,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
title,
alt,
caption,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
@@ -1696,8 +1700,8 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
>
<span className="quick-action-icon">🤖</span>
<span className="quick-action-text">
<strong>AI: Generate Alt & Caption</strong>
<small>Uses Claude Sonnet 4.5 to analyze the image</small>
<strong>AI: Generate Title, Alt & Caption</strong>
<small>Analyzes the image to suggest metadata</small>
</span>
</button>
</div>
@@ -1755,6 +1759,15 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
</div>
)}
</div>
<div className="editor-field">
<label>Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title for lists and search results"
/>
</div>
<div className="editor-field">
<label>Alt Text</label>
<input

View File

@@ -11,17 +11,17 @@ interface PostSearchResult {
interface MediaSearchResult {
id: string;
originalName: string;
caption?: string;
title?: string;
mimeType: string;
createdAt: string;
}
/** Get display name for media: caption (truncated to 60 chars) or fallback to filename */
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
function getMediaDisplayName(media: MediaSearchResult): string {
if (media.caption) {
return media.caption.length > 60
? media.caption.substring(0, 60) + '...'
: media.caption;
if (media.title) {
return media.title.length > 60
? media.title.substring(0, 60) + '...'
: media.title;
}
return media.originalName;
}
@@ -187,7 +187,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const externalLabel = mode === 'link' ? 'External URL' : 'External Image';
const searchPlaceholder = mode === 'link'
? 'Search posts by title or content...'
: 'Search media by name, caption, or alt text...';
: 'Search media by name, title, or alt text...';
return (
<div className="insert-modal-backdrop" onClick={handleBackdropClick}>

View File

@@ -14,12 +14,12 @@ import { useAppStore, MediaData } from '../../store';
import { showToast } from '../Toast';
import './LinkedMediaPanel.css';
/** Get display name for media: caption (truncated to 60 chars) or fallback to filename */
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
function getMediaDisplayName(media: MediaData): string {
if (media.caption) {
return media.caption.length > 60
? media.caption.substring(0, 60) + '...'
: media.caption;
if (media.title) {
return media.title.length > 60
? media.title.substring(0, 60) + '...'
: media.title;
}
return media.originalName;
}

View File

@@ -329,7 +329,7 @@ export const SettingsView: React.FC = () => {
<SettingRow
id="project-language"
label="Main Language"
description="The primary language for your blog content. AI-generated alt text and captions will use this language."
description="The primary language for your blog content. AI-generated titles, alt text, and captions will use this language."
>
<select
id="project-language"

View File

@@ -5,12 +5,12 @@ import { groupPostsByStatus } from '../../utils';
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import './Sidebar.css';
/** Get display name for media: caption (truncated to 60 chars) or fallback to filename */
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
function getMediaDisplayName(media: MediaData): string {
if (media.caption) {
return media.caption.length > 60
? media.caption.substring(0, 60) + '...'
: media.caption;
if (media.title) {
return media.title.length > 60
? media.title.substring(0, 60) + '...'
: media.title;
}
return media.originalName;
}

View File

@@ -54,6 +54,7 @@ export interface MediaData {
size: number;
width?: number;
height?: number;
title?: string;
alt?: string;
caption?: string;
createdAt: string;

View File

@@ -85,6 +85,7 @@ export interface MediaData {
size: number;
width?: number;
height?: number;
title?: string;
alt?: string;
caption?: string;
createdAt: string;
@@ -101,6 +102,7 @@ export interface MediaFilter {
export interface MediaSearchResult {
id: string;
originalName: string;
title?: string;
mimeType: string;
createdAt: string;
}
@@ -424,7 +426,7 @@ export interface ElectronAPI {
analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => Promise<{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }>;
// Media Analysis
analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; alt?: string; caption?: string; error?: string }>;
analyzeMediaImage: (mediaId: string, language?: string) => Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }>;
// Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;

View File

@@ -52,7 +52,7 @@ const insertedPosts: Array<{
const insertedMedia: Array<{
id: string;
linkedPostIds: string[];
caption?: string;
title?: string;
}> = [];
const createdTags: string[] = [];
@@ -167,7 +167,7 @@ const mockMediaEngine = {
id: `media-${Math.random().toString(36).substr(2, 9)}`,
filename: path.basename(sourcePath),
originalName: metadata?.originalName || path.basename(sourcePath),
caption: metadata?.caption,
title: metadata?.title,
linkedPostIds: metadata?.linkedPostIds || [],
};
insertedMedia.push(result);
@@ -1044,9 +1044,9 @@ describe('ImportExecutionEngine E2E Tests', () => {
expect(result.media.imported).toBe(1);
// Should be imported with caption from WXR title
// Should be imported with title from WXR title
expect(insertedMedia.length).toBe(1);
expect(insertedMedia[0].caption).toBe('standalone-logo');
expect(insertedMedia[0].title).toBe('standalone-logo');
// No linked posts (standalone)
expect(insertedMedia[0].linkedPostIds.length).toBe(0);

View File

@@ -1016,7 +1016,7 @@ describe('ImportExecutionEngine', () => {
);
});
it('should set caption from WXR title', async () => {
it('should set title from WXR title', async () => {
const wxrMedia = createMockWxrMedia({ title: 'Beautiful Sunset' });
const report = createMockAnalysisReport({
media: {
@@ -1035,7 +1035,7 @@ describe('ImportExecutionEngine', () => {
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
caption: 'Beautiful Sunset',
title: 'Beautiful Sunset',
})
);
});

View File

@@ -535,11 +535,19 @@ describe('MediaEngine', () => {
});
});
describe('Alt Text and Caption', () => {
describe('Title, Alt Text and Caption', () => {
beforeEach(() => {
mockFiles.set('/source/image.jpg', Buffer.from('image-data'));
});
it('should store title for display in lists and search', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
title: 'Mountain Sunrise Photo',
});
expect(media.title).toBe('Mountain Sunrise Photo');
});
it('should store alt text for accessibility', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', {
alt: 'A scenic mountain view',
@@ -556,9 +564,10 @@ describe('MediaEngine', () => {
expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024');
});
it('should handle media without alt or caption', async () => {
it('should handle media without title, alt or caption', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media.title).toBeUndefined();
expect(media.alt).toBeUndefined();
expect(media.caption).toBeUndefined();
});
@@ -847,10 +856,36 @@ linkedPostIds: ["post-a", "post-b", "post-c"]`;
return chain;
});
const result = await mediaEngine.updateMedia('non-existent-id', { caption: 'New caption' });
const result = await mediaEngine.updateMedia('non-existent-id', { title: 'New title' });
expect(result).toBeNull();
});
it('should update media title', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: 'media-id',
projectId: 'default',
originalName: 'test.jpg',
mimeType: 'image/jpeg',
size: 1024,
filePath: '/mock/media/test.jpg',
title: 'Old title',
createdAt: new Date(),
updatedAt: new Date(),
}),
});
return chain;
});
const result = await mediaEngine.updateMedia('media-id', { title: 'New title' });
expect(result).not.toBeNull();
expect(result?.title).toBe('New title');
});
it('should update media caption', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();

View File

@@ -64,6 +64,7 @@ export function createMockMedia(overrides?: Partial<MediaData>): MediaData {
size: 1024 * 100, // 100KB
width: 800,
height: 600,
title: 'Test Image Title',
alt: 'Test image',
caption: 'A test image caption',
createdAt: now,