feat: support captions for search results and sidebar for media
This commit is contained in:
@@ -61,6 +61,7 @@ export interface MediaFilter {
|
|||||||
export interface MediaSearchResult {
|
export interface MediaSearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
|
caption?: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -767,6 +768,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,
|
||||||
mimeType: item.mimeType,
|
mimeType: item.mimeType,
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,50 @@ export type SupportedLanguage =
|
|||||||
| 'tamil'
|
| 'tamil'
|
||||||
| 'turkish';
|
| 'turkish';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of ISO 639-1 language codes to Snowball stemmer language names.
|
||||||
|
* Falls back to 'english' for unsupported codes.
|
||||||
|
*/
|
||||||
|
const isoToSnowball: Record<string, SupportedLanguage> = {
|
||||||
|
ar: 'arabic',
|
||||||
|
hy: 'armenian',
|
||||||
|
eu: 'basque',
|
||||||
|
ca: 'catalan',
|
||||||
|
cs: 'czech',
|
||||||
|
da: 'danish',
|
||||||
|
nl: 'dutch',
|
||||||
|
en: 'english',
|
||||||
|
fi: 'finnish',
|
||||||
|
fr: 'french',
|
||||||
|
de: 'german',
|
||||||
|
hu: 'hungarian',
|
||||||
|
it: 'italian',
|
||||||
|
ga: 'irish',
|
||||||
|
no: 'norwegian',
|
||||||
|
nb: 'norwegian',
|
||||||
|
nn: 'norwegian',
|
||||||
|
pt: 'portuguese',
|
||||||
|
ro: 'romanian',
|
||||||
|
ru: 'russian',
|
||||||
|
es: 'spanish',
|
||||||
|
sl: 'slovene',
|
||||||
|
sv: 'swedish',
|
||||||
|
ta: 'tamil',
|
||||||
|
tr: 'turkish',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an ISO 639-1 language code to a Snowball stemmer language name.
|
||||||
|
* Returns 'english' as fallback for unknown codes.
|
||||||
|
*
|
||||||
|
* @param isoCode - ISO 639-1 language code (e.g., 'en', 'de', 'fr')
|
||||||
|
* @returns Snowball language name (e.g., 'english', 'german', 'french')
|
||||||
|
*/
|
||||||
|
export function isoToStemmerLanguage(isoCode: string): SupportedLanguage {
|
||||||
|
const normalized = isoCode.toLowerCase().split('-')[0]; // Handle 'en-US' -> 'en'
|
||||||
|
return isoToSnowball[normalized] || 'english';
|
||||||
|
}
|
||||||
|
|
||||||
interface Stemmer {
|
interface Stemmer {
|
||||||
stem(word: string): string;
|
stem(word: string): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// Sync meta on startup
|
// Sync meta on startup
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
// Set search language from project metadata
|
||||||
|
const { isoToStemmerLanguage } = await import('../engine/stemmer');
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
if (metadata?.mainLanguage) {
|
||||||
|
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
|
||||||
|
postEngine.setSearchLanguage(stemmerLang);
|
||||||
|
mediaEngine.setSearchLanguage(stemmerLang);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
@@ -112,6 +121,15 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// Sync meta on project switch
|
// Sync meta on project switch
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
// Set search language from project metadata
|
||||||
|
const { isoToStemmerLanguage } = await import('../engine/stemmer');
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
if (metadata?.mainLanguage) {
|
||||||
|
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
|
||||||
|
postEngine.setSearchLanguage(stemmerLang);
|
||||||
|
mediaEngine.setSearchLanguage(stemmerLang);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
|
|||||||
@@ -11,10 +11,21 @@ interface PostSearchResult {
|
|||||||
interface MediaSearchResult {
|
interface MediaSearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
|
caption?: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get display name for media: caption (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;
|
||||||
|
}
|
||||||
|
return media.originalName;
|
||||||
|
}
|
||||||
|
|
||||||
type SearchResult = PostSearchResult | MediaSearchResult;
|
type SearchResult = PostSearchResult | MediaSearchResult;
|
||||||
|
|
||||||
type InsertMode = 'link' | 'image';
|
type InsertMode = 'link' | 'image';
|
||||||
@@ -251,7 +262,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="insert-modal-result-title">{result.originalName}</div>
|
<div className="insert-modal-result-title">{getMediaDisplayName(result)}</div>
|
||||||
<div className="insert-modal-result-meta">
|
<div className="insert-modal-result-meta">
|
||||||
{result.mimeType} • {new Date(result.createdAt).toLocaleDateString()}
|
{result.mimeType} • {new Date(result.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ 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 */
|
||||||
|
function getMediaDisplayName(media: MediaData): string {
|
||||||
|
if (media.caption) {
|
||||||
|
return media.caption.length > 60
|
||||||
|
? media.caption.substring(0, 60) + '...'
|
||||||
|
: media.caption;
|
||||||
|
}
|
||||||
|
return media.originalName;
|
||||||
|
}
|
||||||
|
|
||||||
interface LinkedMediaPanelProps {
|
interface LinkedMediaPanelProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
@@ -181,7 +191,12 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
|||||||
const unlinkedMedia = allMedia.filter(
|
const unlinkedMedia = allMedia.filter(
|
||||||
m => !linkedMedia.find(l => l.id === m.id)
|
m => !linkedMedia.find(l => l.id === m.id)
|
||||||
).filter(
|
).filter(
|
||||||
m => !mediaSearchQuery || m.originalName.toLowerCase().includes(mediaSearchQuery.toLowerCase())
|
m => {
|
||||||
|
if (!mediaSearchQuery) return true;
|
||||||
|
const query = mediaSearchQuery.toLowerCase();
|
||||||
|
return m.originalName.toLowerCase().includes(query) ||
|
||||||
|
(m.caption && m.caption.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
@@ -244,14 +259,14 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
|||||||
key={media.id}
|
key={media.id}
|
||||||
className="media-picker-item"
|
className="media-picker-item"
|
||||||
onClick={() => handleLinkExisting(media.id)}
|
onClick={() => handleLinkExisting(media.id)}
|
||||||
title={media.originalName}
|
title={media.caption || media.originalName}
|
||||||
>
|
>
|
||||||
{media.mimeType?.startsWith('image/') ? (
|
{media.mimeType?.startsWith('image/') ? (
|
||||||
<img src={`bds-media://${media.id}`} alt={media.originalName} />
|
<img src={`bds-media://${media.id}`} alt={media.alt || media.originalName} />
|
||||||
) : (
|
) : (
|
||||||
<div className="media-icon">📄</div>
|
<div className="media-icon">📄</div>
|
||||||
)}
|
)}
|
||||||
<span className="media-name">{media.originalName}</span>
|
<span className="media-name">{getMediaDisplayName(media)}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -290,8 +305,8 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
|||||||
<div className="media-icon">📄</div>
|
<div className="media-icon">📄</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="media-name" title={media.originalName}>
|
<span className="media-name" title={media.caption || media.originalName}>
|
||||||
{media.originalName}
|
{getMediaDisplayName(media)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="unlink-btn"
|
className="unlink-btn"
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { showToast } from '../Toast';
|
|||||||
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 */
|
||||||
|
function getMediaDisplayName(media: MediaData): string {
|
||||||
|
if (media.caption) {
|
||||||
|
return media.caption.length > 60
|
||||||
|
? media.caption.substring(0, 60) + '...'
|
||||||
|
: media.caption;
|
||||||
|
}
|
||||||
|
return media.originalName;
|
||||||
|
}
|
||||||
|
|
||||||
// Tag data with color information
|
// Tag data with color information
|
||||||
interface TagData {
|
interface TagData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -946,7 +956,7 @@ const MediaList: React.FC = () => {
|
|||||||
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
|
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
|
||||||
onClick={() => handleMediaClick(item.id)}
|
onClick={() => handleMediaClick(item.id)}
|
||||||
onDoubleClick={() => handleMediaDoubleClick(item.id)}
|
onDoubleClick={() => handleMediaDoubleClick(item.id)}
|
||||||
title={item.originalName}
|
title={item.caption || item.originalName}
|
||||||
>
|
>
|
||||||
{item.mimeType.startsWith('image/') ? (
|
{item.mimeType.startsWith('image/') ? (
|
||||||
<div className="media-thumbnail">
|
<div className="media-thumbnail">
|
||||||
@@ -967,7 +977,7 @@ const MediaList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="media-item-info">
|
<div className="media-item-info">
|
||||||
<div className="media-item-name truncate">{item.originalName}</div>
|
<div className="media-item-name truncate">{getMediaDisplayName(item)}</div>
|
||||||
<div className="media-item-size">{formatFileSize(item.size)}</div>
|
<div className="media-item-size">{formatFileSize(item.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user