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)
|
name = Column(String(255), nullable=False)
|
||||||
source_language = Column(String(10), nullable=False, default="fr")
|
source_language = Column(String(10), nullable=False, default="fr")
|
||||||
|
target_language = Column(String(10), nullable=True, default="en")
|
||||||
created_at = Column(DateTime, default=_utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
@@ -348,6 +349,7 @@ class Glossary(Base):
|
|||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"source_language": self.source_language,
|
"source_language": self.source_language,
|
||||||
|
"target_language": self.target_language,
|
||||||
"terms": [term.to_dict() for term in self.terms] if self.terms else [],
|
"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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { SUPPORTED_LANGUAGES } from './types';
|
||||||
|
|
||||||
interface CreateGlossaryDialogProps {
|
interface CreateGlossaryDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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>;
|
onImportTemplate: (templateId: string, name?: string) => Promise<void>;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
isImportingTemplate: boolean;
|
isImportingTemplate: boolean;
|
||||||
@@ -289,6 +291,8 @@ export function CreateGlossaryDialog({
|
|||||||
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
|
const [activeTab, setActiveTab] = useState<'templates' | 'file' | 'manual'>('templates');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [nameAutoFilled, setNameAutoFilled] = useState(false);
|
const [nameAutoFilled, setNameAutoFilled] = useState(false);
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('fr');
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState('en');
|
||||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||||
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
|
const [fileTerms, setFileTerms] = useState<GlossaryTermInput[]>([]);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<GlossaryTemplate | null>(null);
|
||||||
@@ -300,6 +304,8 @@ export function CreateGlossaryDialog({
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setName('');
|
setName('');
|
||||||
setNameAutoFilled(false);
|
setNameAutoFilled(false);
|
||||||
|
setSourceLanguage('fr');
|
||||||
|
setTargetLanguage('en');
|
||||||
setTerms([{ source: '', target: '' }]);
|
setTerms([{ source: '', target: '' }]);
|
||||||
setFileTerms([]);
|
setFileTerms([]);
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
@@ -340,9 +346,9 @@ export function CreateGlossaryDialog({
|
|||||||
const termsToSave = activeTab === 'file'
|
const termsToSave = activeTab === 'file'
|
||||||
? fileTerms
|
? fileTerms
|
||||||
: terms.filter(t => t.source.trim() && t.target.trim());
|
: 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();
|
reset();
|
||||||
}, [activeTab, selectedTemplate, name, fileTerms, terms, onCreate, onImportTemplate, reset]);
|
}, [activeTab, selectedTemplate, name, fileTerms, terms, sourceLanguage, targetLanguage, onCreate, onImportTemplate, reset]);
|
||||||
|
|
||||||
const canSubmit = (() => {
|
const canSubmit = (() => {
|
||||||
if (!name.trim() || isProcessing) return false;
|
if (!name.trim() || isProcessing) return false;
|
||||||
@@ -381,15 +387,48 @@ export function CreateGlossaryDialog({
|
|||||||
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
|
<DialogDescription>{t('glossaries.dialog.description')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="shrink-0 space-y-2 pt-2">
|
<div className="shrink-0 space-y-3 pt-2">
|
||||||
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
<div>
|
||||||
<Input
|
<Label htmlFor="glossary-name">{t('glossaries.dialog.nameLabel')}</Label>
|
||||||
id="glossary-name"
|
<Input
|
||||||
value={name}
|
id="glossary-name"
|
||||||
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
value={name}
|
||||||
placeholder={t('glossaries.dialog.namePlaceholder')}
|
onChange={(e) => { setName(e.target.value); setNameAutoFilled(false); }}
|
||||||
disabled={isProcessing}
|
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>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function GlossariesPage() {
|
|||||||
setDeleteDialogOpen(true);
|
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 {
|
try {
|
||||||
await createGlossary(data);
|
await createGlossary(data);
|
||||||
setCreateDialogOpen(false);
|
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">
|
<h3 className="text-lg font-serif font-medium text-brand-dark dark:text-white tracking-tight mb-1 truncate">
|
||||||
{glossary.name}
|
{glossary.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-medium">
|
<p className="text-xs text-brand-dark/40 dark:text-white/40 font-medium flex items-center gap-1.5">
|
||||||
{SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.flag ?? '🌐'} {SUPPORTED_LANGUAGES.find(l => l.code === glossary.source_language)?.label ?? glossary.source_language}
|
<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>
|
</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">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Glossary {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source_language: string;
|
source_language: string;
|
||||||
|
target_language: string;
|
||||||
terms: GlossaryTerm[];
|
terms: GlossaryTerm[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -19,6 +20,7 @@ export interface GlossaryListItem {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source_language: string;
|
source_language: string;
|
||||||
|
target_language: string;
|
||||||
terms_count: number;
|
terms_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -61,12 +63,14 @@ export interface GlossaryTermInputWithId extends GlossaryTermInput {
|
|||||||
export interface GlossaryCreateInput {
|
export interface GlossaryCreateInput {
|
||||||
name: string;
|
name: string;
|
||||||
source_language?: string;
|
source_language?: string;
|
||||||
|
target_language?: string;
|
||||||
terms?: GlossaryTermInput[];
|
terms?: GlossaryTermInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlossaryUpdateInput {
|
export interface GlossaryUpdateInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
source_language?: string;
|
source_language?: string;
|
||||||
|
target_language?: string;
|
||||||
terms?: GlossaryTermInput[];
|
terms?: GlossaryTermInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ def _format_glossary(glossary: Glossary) -> dict:
|
|||||||
"id": glossary.id,
|
"id": glossary.id,
|
||||||
"name": glossary.name,
|
"name": glossary.name,
|
||||||
"source_language": glossary.source_language,
|
"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 [],
|
"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,
|
"created_at": glossary.created_at.isoformat() if glossary.created_at else None,
|
||||||
"updated_at": glossary.updated_at.isoformat() if glossary.updated_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,
|
user_id=user.id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
source_language=body.source_language,
|
source_language=body.source_language,
|
||||||
|
target_language=body.target_language,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
updated_at=datetime.now(timezone.utc),
|
updated_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
@@ -185,6 +187,7 @@ async def list_glossaries(
|
|||||||
id=g.id,
|
id=g.id,
|
||||||
name=g.name,
|
name=g.name,
|
||||||
source_language=g.source_language or "fr",
|
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,
|
terms_count=len(g.terms) if g.terms else 0,
|
||||||
created_at=g.created_at,
|
created_at=g.created_at,
|
||||||
)
|
)
|
||||||
@@ -339,6 +342,8 @@ async def update_glossary(
|
|||||||
if body.source_language is not None:
|
if body.source_language is not None:
|
||||||
glossary.source_language = body.source_language
|
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:
|
if body.terms is not None:
|
||||||
# Delete existing terms
|
# Delete existing terms
|
||||||
session.query(GlossaryTerm).filter(
|
session.query(GlossaryTerm).filter(
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class GlossaryCreate(BaseModel):
|
|||||||
source_language: str = Field(
|
source_language: str = Field(
|
||||||
default="fr", max_length=10, description="Langue source (ISO code)"
|
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(
|
terms: list[GlossaryTermCreate] = Field(
|
||||||
default_factory=list, description="Liste des termes"
|
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)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
source_language: Optional[str] = Field(None, max_length=10)
|
source_language: Optional[str] = Field(None, max_length=10)
|
||||||
|
target_language: Optional[str] = Field(None, max_length=10)
|
||||||
terms: Optional[list[GlossaryTermCreate]] = Field(None)
|
terms: Optional[list[GlossaryTermCreate]] = Field(None)
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@@ -75,6 +79,7 @@ class GlossaryResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
source_language: str = "fr"
|
source_language: str = "fr"
|
||||||
|
target_language: str = "en"
|
||||||
terms: list[GlossaryTermResponse] = []
|
terms: list[GlossaryTermResponse] = []
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
@@ -88,6 +93,7 @@ class GlossaryListItem(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
source_language: str = "fr"
|
source_language: str = "fr"
|
||||||
|
target_language: str = "en"
|
||||||
terms_count: int = Field(
|
terms_count: int = Field(
|
||||||
default=0, description="Nombre de termes dans le glossaire"
|
default=0, description="Nombre de termes dans le glossaire"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user