feat(glossaries): add target_language support - DB migration, API, and UI language pair display
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m52s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m52s
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Add target_language to glossaries
|
||||
|
||||
Revision ID: e5b2c9d1f4a8
|
||||
Revises: d4a1f8e2b3c7
|
||||
Create Date: 2026-05-31
|
||||
|
||||
Adds target_language column to glossaries table.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "e5b2c9d1f4a8"
|
||||
down_revision = "d4a1f8e2b3c7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"glossaries",
|
||||
sa.Column("target_language", sa.String(10), nullable=True, server_default="en"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("glossaries", "target_language")
|
||||
@@ -331,6 +331,7 @@ class Glossary(Base):
|
||||
)
|
||||
name = Column(String(255), nullable=False)
|
||||
source_language = Column(String(10), nullable=False, default="fr")
|
||||
target_language = Column(String(10), nullable=True, default="en")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
@@ -348,6 +349,7 @@ class Glossary(Base):
|
||||
"user_id": self.user_id,
|
||||
"name": self.name,
|
||||
"source_language": self.source_language,
|
||||
"target_language": self.target_language,
|
||||
"terms": [term.to_dict() for term in self.terms] if self.terms else [],
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
|
||||
@@ -40,10 +40,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { SUPPORTED_LANGUAGES } from './types';
|
||||
|
||||
interface CreateGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
onCreate: (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
isImportingTemplate: boolean;
|
||||
@@ -289,6 +291,8 @@ export function CreateGlossaryDialog({
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
|
||||
const [name, setName] = useState('');
|
||||
const [nameAutoFilled, setNameAutoFilled] = useState(false);
|
||||
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||
const [targetLanguage, setTargetLanguage] = useState('en');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
|
||||
@@ -300,6 +304,8 @@ export function CreateGlossaryDialog({
|
||||
const reset = useCallback(() => {
|
||||
setName('');
|
||||
setNameAutoFilled(false);
|
||||
setSourceLanguage('fr');
|
||||
setTargetLanguage('en');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
setFileTerms([]);
|
||||
setSelectedTemplate(null);
|
||||
@@ -340,9 +346,9 @@ export function CreateGlossaryDialog({
|
||||
const termsToSave = activeTab === 'file'
|
||||
? fileTerms
|
||||
: terms.filter(t => t.source.trim() && t.target.trim());
|
||||
await onCreate({ name: name.trim(), terms: termsToSave });
|
||||
await onCreate({ name: name.trim(), source_language: sourceLanguage, target_language: targetLanguage, terms: termsToSave });
|
||||
reset();
|
||||
}, [activeTab, selectedTemplate, name, fileTerms, terms, onCreate, onImportTemplate, reset]);
|
||||
}, [activeTab, selectedTemplate, name, fileTerms, terms, sourceLanguage, targetLanguage, onCreate, onImportTemplate, reset]);
|
||||
|
||||
const canSubmit = (() => {
|
||||
if (!name.trim() || isProcessing) return false;
|
||||
@@ -381,15 +387,48 @@ export function CreateGlossaryDialog({
|
||||
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="shrink-0 space-y-2 pt-2">
|
||||
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
||||
placeholder={t('glossaries.dialog.namePlaceholder')}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<div className="shrink-0 space-y-3 pt-2">
|
||||
<div>
|
||||
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
||||
placeholder={t('glossaries.dialog.namePlaceholder')}
|
||||
disabled={isProcessing}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/* Language pair selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Langue source</Label>
|
||||
<select
|
||||
value={sourceLanguage}
|
||||
onChange={e => setSourceLanguage(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="pt-5 text-muted-foreground font-bold">→</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">Langue cible</Label>
|
||||
<select
|
||||
value={targetLanguage}
|
||||
onChange={e => setTargetLanguage(e.target.value)}
|
||||
disabled={isProcessing}
|
||||
className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function GlossariesPage() {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateGlossary = async (data: { name: string; terms: GlossaryTermInput[] }) => {
|
||||
const handleCreateGlossary = async (data: { name: string; source_language: string; target_language: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await createGlossary(data);
|
||||
setCreateDialogOpen(false);
|
||||
@@ -501,8 +501,12 @@ export default function GlossariesPage() {
|
||||
<h3 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight mb-1 truncate">
|
||||
{glossary.name}
|
||||
</h3>
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-medium">
|
||||
{SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.flag ?? '🌐'} {SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.label ?? glossary.source_language}
|
||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1.5">
|
||||
<span>{SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.flag ?? '🌐'}</span>
|
||||
<span>{SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.label ?? glossary.source_language}</span>
|
||||
<span className="text-brand-accent font-bold">→</span>
|
||||
<span>{SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language)?.flag ?? '🌐'}</span>
|
||||
<span>{SUPPORTED_LANGUAGES.find(l => l.code === glossary.target_language)?.label ?? glossary.target_language}</span>
|
||||
</p>
|
||||
<div className="flex justify-between items-center pt-4 mt-5 border-t border-black/5 dark:border-white/10 text-xs text-brand-dark/40 dark:text-white/40">
|
||||
<span className="flex items-center gap-1">
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Glossary {
|
||||
id: string;
|
||||
name: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
terms: GlossaryTerm[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -19,6 +20,7 @@ export interface GlossaryListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
terms_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -61,12 +63,14 @@ export interface GlossaryTermInputWithId extends GlossaryTermInput {
|
||||
export interface GlossaryCreateInput {
|
||||
name: string;
|
||||
source_language?: string;
|
||||
target_language?: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
export interface GlossaryUpdateInput {
|
||||
name?: string;
|
||||
source_language?: string;
|
||||
target_language?: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ def _format_glossary(glossary: Glossary) -> dict:
|
||||
"id": glossary.id,
|
||||
"name": glossary.name,
|
||||
"source_language": glossary.source_language,
|
||||
"target_language": getattr(glossary, "target_language", "en") or "en",
|
||||
"terms": [_format_term(t) for t in glossary.terms] if glossary.terms else [],
|
||||
"created_at": glossary.created_at.isoformat() if glossary.created_at else None,
|
||||
"updated_at": glossary.updated_at.isoformat() if glossary.updated_at else None,
|
||||
@@ -106,6 +107,7 @@ async def create_glossary(
|
||||
user_id=user.id,
|
||||
name=body.name,
|
||||
source_language=body.source_language,
|
||||
target_language=body.target_language,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -185,6 +187,7 @@ async def list_glossaries(
|
||||
id=g.id,
|
||||
name=g.name,
|
||||
source_language=g.source_language or "fr",
|
||||
target_language=getattr(g, "target_language", None) or "en",
|
||||
terms_count=len(g.terms) if g.terms else 0,
|
||||
created_at=g.created_at,
|
||||
)
|
||||
@@ -339,6 +342,8 @@ async def update_glossary(
|
||||
if body.source_language is not None:
|
||||
glossary.source_language = body.source_language
|
||||
|
||||
if body.target_language is not None:
|
||||
glossary.target_language = body.target_language
|
||||
if body.terms is not None:
|
||||
# Delete existing terms
|
||||
session.query(GlossaryTerm).filter(
|
||||
|
||||
@@ -46,6 +46,9 @@ class GlossaryCreate(BaseModel):
|
||||
source_language: str = Field(
|
||||
default="fr", max_length=10, description="Langue source (ISO code)"
|
||||
)
|
||||
target_language: str = Field(
|
||||
default="en", max_length=10, description="Langue cible (ISO code)"
|
||||
)
|
||||
terms: list[GlossaryTermCreate] = Field(
|
||||
default_factory=list, description="Liste des termes"
|
||||
)
|
||||
@@ -61,6 +64,7 @@ class GlossaryUpdate(BaseModel):
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
source_language: Optional[str] = Field(None, max_length=10)
|
||||
target_language: Optional[str] = Field(None, max_length=10)
|
||||
terms: Optional[list[GlossaryTermCreate]] = Field(None)
|
||||
|
||||
@field_validator("name")
|
||||
@@ -75,6 +79,7 @@ class GlossaryResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
source_language: str = "fr"
|
||||
target_language: str = "en"
|
||||
terms: list[GlossaryTermResponse] = []
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
@@ -88,6 +93,7 @@ class GlossaryListItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
source_language: str = "fr"
|
||||
target_language: str = "en"
|
||||
terms_count: int = Field(
|
||||
default=0, description="Nombre de termes dans le glossaire"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user