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, "when": 1771088786493,
"tag": "0001_narrow_black_bolt", "tag": "0001_narrow_black_bolt",
"breakpoints": true "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(), size: integer('size').notNull(),
width: integer('width'), width: integer('width'),
height: integer('height'), height: integer('height'),
title: text('title'),
alt: text('alt'), alt: text('alt'),
caption: text('caption'), caption: text('caption'),
filePath: text('file_path').notNull(), filePath: text('file_path').notNull(),

View File

@@ -313,7 +313,7 @@ Available Tools:
- list_media: List media files with optional MIME type filtering. - 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. - 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_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_tags: List all tags with post counts.
- list_categories: List all categories 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. - 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 // Import the media file
const mediaEngine = getMediaEngine(); const mediaEngine = getMediaEngine();
await mediaEngine.importMedia(sourcePath, { await mediaEngine.importMedia(sourcePath, {
caption: wxrMedia.title || undefined, title: wxrMedia.title || undefined,
alt: wxrMedia.description || undefined, alt: wxrMedia.description || undefined,
mimeType: wxrMedia.mimeType, mimeType: wxrMedia.mimeType,
tags: [], tags: [],

View File

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

View File

@@ -739,11 +739,12 @@ export class OpenCodeManager {
}, },
{ {
name: 'update_media_metadata', 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: { input_schema: {
type: 'object', type: 'object',
properties: { properties: {
mediaId: { type: 'string', description: 'The unique ID of the media to update' }, 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' }, alt: { type: 'string', description: 'New alt text for the image' },
caption: { type: 'string', description: 'New caption for the image' }, caption: { type: 'string', description: 'New caption for the image' },
tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the media' }, 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, id: media.id, filename: media.filename,
originalName: media.originalName, mimeType: media.mimeType, originalName: media.originalName, mimeType: media.mimeType,
size: media.size, width: media.width, height: media.height, 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, createdAt: media.createdAt, updatedAt: media.updatedAt,
}, },
}; };
@@ -945,7 +946,7 @@ export class OpenCodeManager {
media: mediaList.map(m => ({ media: mediaList.map(m => ({
id: m.id, filename: m.filename, id: m.id, filename: m.filename,
originalName: m.originalName, mimeType: m.mimeType, 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': { case 'update_media_metadata': {
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
if (args.title !== undefined) updates.title = args.title;
if (args.alt !== undefined) updates.alt = args.alt; if (args.alt !== undefined) updates.alt = args.alt;
if (args.caption !== undefined) updates.caption = args.caption; if (args.caption !== undefined) updates.caption = args.caption;
if (args.tags !== undefined) updates.tags = args.tags; if (args.tags !== undefined) updates.tags = args.tags;
@@ -1033,6 +1035,7 @@ export class OpenCodeManager {
originalName: mediaItem.originalName, originalName: mediaItem.originalName,
width: mediaItem.width, width: mediaItem.width,
height: mediaItem.height, height: mediaItem.height,
title: mediaItem.title,
alt: mediaItem.alt, alt: mediaItem.alt,
caption: mediaItem.caption, caption: mediaItem.caption,
size: size, size: size,
@@ -1080,6 +1083,7 @@ export class OpenCodeManager {
filename: link.media.filename, filename: link.media.filename,
originalName: link.media.originalName, originalName: link.media.originalName,
mimeType: link.media.mimeType, mimeType: link.media.mimeType,
title: link.media.title,
alt: link.media.alt, alt: link.media.alt,
caption: link.media.caption, caption: link.media.caption,
width: link.media.width, 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 * This is a one-shot request that looks at the image and suggests metadata
*/ */
async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{ async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{
success: boolean; success: boolean;
title?: string;
alt?: string; alt?: string;
caption?: string; caption?: string;
error?: 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 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" 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). CAPTION: Short, engaging blog caption (5-20 words).
Respond with JSON only: {"alt": "...", "caption": "..."}`; Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
try { try {
// Using Claude Sonnet 4.5 for best image analysis // Using Claude Sonnet 4.5 for best image analysis
@@ -1570,6 +1576,7 @@ Respond with JSON only: {"alt": "...", "caption": "..."}`;
return { return {
success: true, success: true,
title: result.title || undefined,
alt: result.alt || undefined, alt: result.alt || undefined,
caption: result.caption || undefined, caption: result.caption || undefined,
}; };

View File

@@ -333,7 +333,7 @@ export function registerChatHandlers(): void {
// ============ Media Analysis ============ // ============ 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) => { ipcMain.handle('chat:analyzeMediaImage', async (_, mediaId: string, language?: string) => {
try { try {
const manager = getOpenCodeManager(); const manager = getOpenCodeManager();

View File

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

View File

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

View File

@@ -14,12 +14,12 @@ import { useAppStore, MediaData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import './LinkedMediaPanel.css'; 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 { function getMediaDisplayName(media: MediaData): string {
if (media.caption) { if (media.title) {
return media.caption.length > 60 return media.title.length > 60
? media.caption.substring(0, 60) + '...' ? media.title.substring(0, 60) + '...'
: media.caption; : media.title;
} }
return media.originalName; return media.originalName;
} }

View File

@@ -329,7 +329,7 @@ export const SettingsView: React.FC = () => {
<SettingRow <SettingRow
id="project-language" id="project-language"
label="Main 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 <select
id="project-language" id="project-language"

View File

@@ -5,12 +5,12 @@ import { groupPostsByStatus } from '../../utils';
import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import './Sidebar.css'; 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 { function getMediaDisplayName(media: MediaData): string {
if (media.caption) { if (media.title) {
return media.caption.length > 60 return media.title.length > 60
? media.caption.substring(0, 60) + '...' ? media.title.substring(0, 60) + '...'
: media.caption; : media.title;
} }
return media.originalName; return media.originalName;
} }

View File

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

View File

@@ -85,6 +85,7 @@ export interface MediaData {
size: number; size: number;
width?: number; width?: number;
height?: number; height?: number;
title?: string;
alt?: string; alt?: string;
caption?: string; caption?: string;
createdAt: string; createdAt: string;
@@ -101,6 +102,7 @@ export interface MediaFilter {
export interface MediaSearchResult { export interface MediaSearchResult {
id: string; id: string;
originalName: string; originalName: string;
title?: string;
mimeType: string; mimeType: string;
createdAt: 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 }>; 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 // 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 // Event listeners for streaming/progress
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void; onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;

View File

@@ -52,7 +52,7 @@ const insertedPosts: Array<{
const insertedMedia: Array<{ const insertedMedia: Array<{
id: string; id: string;
linkedPostIds: string[]; linkedPostIds: string[];
caption?: string; title?: string;
}> = []; }> = [];
const createdTags: string[] = []; const createdTags: string[] = [];
@@ -167,7 +167,7 @@ const mockMediaEngine = {
id: `media-${Math.random().toString(36).substr(2, 9)}`, id: `media-${Math.random().toString(36).substr(2, 9)}`,
filename: path.basename(sourcePath), filename: path.basename(sourcePath),
originalName: metadata?.originalName || path.basename(sourcePath), originalName: metadata?.originalName || path.basename(sourcePath),
caption: metadata?.caption, title: metadata?.title,
linkedPostIds: metadata?.linkedPostIds || [], linkedPostIds: metadata?.linkedPostIds || [],
}; };
insertedMedia.push(result); insertedMedia.push(result);
@@ -1044,9 +1044,9 @@ describe('ImportExecutionEngine E2E Tests', () => {
expect(result.media.imported).toBe(1); 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.length).toBe(1);
expect(insertedMedia[0].caption).toBe('standalone-logo'); expect(insertedMedia[0].title).toBe('standalone-logo');
// No linked posts (standalone) // No linked posts (standalone)
expect(insertedMedia[0].linkedPostIds.length).toBe(0); 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 wxrMedia = createMockWxrMedia({ title: 'Beautiful Sunset' });
const report = createMockAnalysisReport({ const report = createMockAnalysisReport({
media: { media: {
@@ -1035,7 +1035,7 @@ describe('ImportExecutionEngine', () => {
expect(mockMediaEngine.importMedia).toHaveBeenCalledWith( expect(mockMediaEngine.importMedia).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
expect.objectContaining({ 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(() => { beforeEach(() => {
mockFiles.set('/source/image.jpg', Buffer.from('image-data')); 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 () => { it('should store alt text for accessibility', async () => {
const media = await mediaEngine.importMedia('/source/image.jpg', { const media = await mediaEngine.importMedia('/source/image.jpg', {
alt: 'A scenic mountain view', alt: 'A scenic mountain view',
@@ -556,9 +564,10 @@ describe('MediaEngine', () => {
expect(media.caption).toBe('Photo taken at Mt. Rainier, 2024'); 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'); const media = await mediaEngine.importMedia('/source/image.jpg');
expect(media.title).toBeUndefined();
expect(media.alt).toBeUndefined(); expect(media.alt).toBeUndefined();
expect(media.caption).toBeUndefined(); expect(media.caption).toBeUndefined();
}); });
@@ -847,10 +856,36 @@ linkedPostIds: ["post-a", "post-b", "post-c"]`;
return chain; 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(); 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 () => { it('should update media caption', async () => {
vi.mocked(mockLocalDb.select).mockImplementation(() => { vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain(); const chain = createSelectChain();

View File

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