feat: support captions for search results and sidebar for media

This commit is contained in:
2026-02-14 15:51:08 +01:00
parent 43ca543934
commit b28993e8b2
6 changed files with 109 additions and 9 deletions

View File

@@ -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,
}); });

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>