diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index ed8b52a..2b27016 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Iterable -from sqlalchemy import case, func, select, Row +from sqlalchemy import Row, case, func, select from sqlalchemy.orm import Session from app.base.exceptions import BaseQueryException @@ -96,7 +96,7 @@ def get_document_records_paged( repetitions_subquery = ( select( DocumentRecord.source, - func.count(DocumentRecord.id).label('repetitions_count') + func.count(DocumentRecord.id).label("repetitions_count"), ) .filter(DocumentRecord.document_id == doc.id) .group_by(DocumentRecord.source) @@ -106,12 +106,14 @@ def get_document_records_paged( return self.__db.execute( select( DocumentRecord, - func.coalesce(repetitions_subquery.c.repetitions_count, 0).label('repetitions_count') + func.coalesce(repetitions_subquery.c.repetitions_count, 0).label( + "repetitions_count" + ), ) .filter(DocumentRecord.document_id == doc.id) .outerjoin( repetitions_subquery, - DocumentRecord.source == repetitions_subquery.c.source + DocumentRecord.source == repetitions_subquery.c.source, ) .order_by(DocumentRecord.id) .offset(page_records * page) @@ -131,19 +133,39 @@ def update_record( raise NotFoundDocumentRecordExc() record.target = data.target + if data.approved is not None: record.approved = data.approved - if data.approved is True: - bound_tm = None - for memory in record.document.memory_associations: - if memory.mode == TmMode.write: - bound_tm = memory.tm_id - - if bound_tm: - TranslationMemoryQuery(self.__db).add_or_update_record( - bound_tm, record.source, record.target + # If update_repetitions is True, find all records with the same source + if data.update_repetitions: + repeated_records = ( + self.__db.execute( + select(DocumentRecord).filter( + DocumentRecord.document_id == record.document_id, + DocumentRecord.source == record.source, ) + ) + .scalars() + .all() + ) + + # Update all repeated records + for repeated_record in repeated_records: + repeated_record.target = data.target + if data.approved is not None: + repeated_record.approved = data.approved + + if data.approved is True: + bound_tm = None + for memory in record.document.memory_associations: + if memory.mode == TmMode.write: + bound_tm = memory.tm_id + + if bound_tm: + TranslationMemoryQuery(self.__db).add_or_update_record( + bound_tm, record.source, record.target + ) self.__db.commit() return record diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 1c07cab..9e251d3 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -36,6 +36,7 @@ class DocumentRecordUpdateResponse(Identified): class DocumentRecordUpdate(BaseModel): target: str approved: Optional[bool] + update_repetitions: bool class DocumentProcessingSettings(BaseModel): diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index 58a175e..187440d 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -152,8 +152,13 @@ def update_doc_record( db: Annotated[Session, Depends(get_db)], ) -> doc_schema.DocumentRecordUpdateResponse: try: - record = GenericDocsQuery(db).update_record(record_id, record) - return record + updated_record = GenericDocsQuery(db).update_record(record_id, record) + return doc_schema.DocumentRecordUpdateResponse( + id=updated_record.id, + source=updated_record.source, + target=updated_record.target, + approved=updated_record.approved, + ) except NotFoundDocumentRecordExc as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Record not found" diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index 773fa6d..79563e5 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -125,8 +125,8 @@ def test_doc_records_returns_404_for_nonexistent_document( @pytest.mark.parametrize( "arguments", [ - {"target": "Updated", "approved": None}, - {"target": "Updated", "approved": True}, + {"target": "Updated", "approved": None, "update_repetitions": False}, + {"target": "Updated", "approved": True, "update_repetitions": False}, ], ) def test_can_update_doc_record( @@ -201,7 +201,12 @@ def test_record_approving_creates_memory( s.commit() response = user_logged_client.put( - "/document/record/1", json={"target": "Updated", "approved": True} + "/document/record/1", + json={ + "target": "Updated", + "approved": True, + "update_repetitions": False, + }, ) assert response.status_code == 200, response.text @@ -251,7 +256,12 @@ def test_record_approving_updates_memory( s.commit() response = user_logged_client.put( - "/document/record/1", json={"target": "Updated", "approved": True} + "/document/record/1", + json={ + "target": "Updated", + "approved": True, + "update_repetitions": False, + }, ) assert response.status_code == 200, response.text @@ -274,7 +284,12 @@ def test_returns_404_for_nonexistent_doc_when_updating_record( user_logged_client: TestClient, ): response = user_logged_client.put( - "/document/record/3", json={"target": "Updated", "approved": None} + "/document/record/3", + json={ + "target": "Updated", + "approved": None, + "update_repetitions": False, + }, ) assert response.status_code == 404 @@ -298,3 +313,90 @@ def test_returns_404_for_nonexistent_record( "/document/1/record/3", json={"target": "Updated"} ) assert response.status_code == 404 + + +def test_can_update_doc_record_with_repetitions( + user_logged_client: TestClient, session: Session +): + """Test updating all records with the same source text""" + with session as s: + records = [ + DocumentRecord(source="Hello World", target="Привет Мир"), + DocumentRecord(source="Hello World", target="Здравствуйте Мир"), + DocumentRecord(source="Goodbye", target="Пока"), + ] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="pending", + created_by=1, + ) + ) + s.commit() + + # Update record 1 with repetition update enabled + response = user_logged_client.put( + "/document/record/1", + json={"target": "Updated Hello", "approved": True, "update_repetitions": True}, + ) + assert response.status_code == 200 + + with session as s: + # Check that all records with "Hello World" source were updated + record1 = s.query(DocumentRecord).filter(DocumentRecord.id == 1).one() + record2 = s.query(DocumentRecord).filter(DocumentRecord.id == 2).one() + record3 = s.query(DocumentRecord).filter(DocumentRecord.id == 3).one() + + assert record1.target == "Updated Hello" + assert record1.approved is True + + assert record2.target == "Updated Hello" # updated + assert record2.approved is True + + assert record3.target == "Пока" # unchanged (different source) + assert record3.approved is False + + +def test_update_repetitions_default_behavior( + user_logged_client: TestClient, session: Session +): + """Test that update_repetitions defaults to False when not specified""" + with session as s: + records = [ + DocumentRecord(source="Hello World", target="Привет Мир"), + DocumentRecord(source="Hello World", target="Здравствуйте Мир"), + ] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="pending", + created_by=1, + ) + ) + s.commit() + + # Update without specifying update_repetitions (should default to False) + response = user_logged_client.put( + "/document/record/1", + json={ + "target": "Updated Hello", + "approved": True, + "update_repetitions": False, + }, + ) + assert response.status_code == 200 + + with session as s: + # Check that only record 1 was updated + record1 = s.query(DocumentRecord).filter(DocumentRecord.id == 1).one() + record2 = s.query(DocumentRecord).filter(DocumentRecord.id == 2).one() + + assert record1.target == "Updated Hello" + assert record1.approved is True + + assert record2.target == "Здравствуйте Мир" # unchanged + assert record2.approved is False diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index 3e423c9..90e52b5 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -917,7 +917,9 @@ def test_setting_glossaries_returns_404_for_non_existing_glossaries( assert response.status_code == 404 -def test_get_doc_records_with_repetitions(user_logged_client: TestClient, session: Session): +def test_get_doc_records_with_repetitions( + user_logged_client: TestClient, session: Session +): """Test that document records endpoint returns repetition counts""" with session as s: records = [ @@ -947,7 +949,9 @@ def test_get_doc_records_with_repetitions(user_logged_client: TestClient, sessio # Check that repetition counts are correct # "Hello World" appears 3 times, others appear once - record_counts = {record["source"]: record["repetitions_count"] for record in response_json} + record_counts = { + record["source"]: record["repetitions_count"] for record in response_json + } assert record_counts["Hello World"] == 3 assert record_counts["Goodbye"] == 1 assert record_counts["Test"] == 1 diff --git a/backend/tests/test_worker.py b/backend/tests/test_worker.py index 3e1da8b..0abbff3 100644 --- a/backend/tests/test_worker.py +++ b/backend/tests/test_worker.py @@ -273,7 +273,7 @@ def test_process_task_uses_correct_tm_ids(session: Session): create_doc(name="small.xliff", type_=DocumentType.xliff), create_xliff_doc(file_data), create_task(), - DocMemoryAssociation(doc_id=1, tm_id=2, mode='read') + DocMemoryAssociation(doc_id=1, tm_id=2, mode="read"), ] ) s.commit() @@ -318,8 +318,8 @@ def test_process_task_uses_tm_mode(mode: str, trans_result: str, session: Sessio create_doc(name="small.xliff", type_=DocumentType.xliff), create_xliff_doc(file_data), create_task(usage=TranslationMemoryUsage(mode)), - DocMemoryAssociation(doc_id=1, tm_id=1, mode='read'), - DocMemoryAssociation(doc_id=1, tm_id=2, mode='read') + DocMemoryAssociation(doc_id=1, tm_id=1, mode="read"), + DocMemoryAssociation(doc_id=1, tm_id=2, mode="read"), ] ) s.commit() diff --git a/backend/worker.py b/backend/worker.py index b4950f2..27e7db7 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -106,7 +106,9 @@ def process_document( ) start_time = time.time() - translate_indices = substitute_segments(settings, session, segments, tm_ids, glossary_ids) + translate_indices = substitute_segments( + settings, session, segments, tm_ids, glossary_ids + ) logging.info( "Segments substitution time: %.2f seconds, speed: %.2f segment/second, segments: %d/%d", time.time() - start_time, diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index 8251a74..e828f09 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -7,12 +7,38 @@ import { } from '../src/client/services/DocumentService' import {DocumentStatus} from '../src/client/schemas/DocumentStatus' +const segments = [ + { + id: 10000, + approved: false, + source: 'Adventure Hooks', + target: 'Зацепки приключения', + repetitions_count: 2, + }, + { + id: 10001, + approved: true, + source: 'Adventure Hooks', + target: 'Зацепки приключения', + repetitions_count: 2, + }, + { + id: 10002, + approved: false, + source: + 'The moment the Cynidiceans pried the horn from the monolith, their city was doomed.', + target: + 'В тот момент, когда кинидийцы извлекли рог из монолита, их город был обречен.', + repetitions_count: 1, + }, +] + const docs = [ { id: 1, created_by: 12, - records_count: 3, - approved_records_count: 1, + records_count: segments.length, + approved_records_count: segments.filter(({approved}) => approved).length, name: 'Some document', status: 'done' as DocumentStatus, type: 'XLIFF', @@ -36,31 +62,9 @@ export const documentMocks = [ ({params}) => { const doc = docs.find((doc) => doc.id === Number(params.id)) if (doc !== undefined) { - return HttpResponse.json>([ - { - id: 1, - approved: false, - source: 'Adventure Hooks', - target: 'Зацепки приключения', - repetitions_count: 2, - }, - { - id: 2, - approved: true, - source: 'Adventure Hooks', - target: 'Зацепки приключения', - repetitions_count: 2, - }, - { - id: 3, - approved: false, - source: - 'The moment the Cynidiceans pried the horn from the monolith, their city was doomed.', - target: - 'В тот момент, когда кинидийцы извлекли рог из монолита, их город был обречен.', - repetitions_count: 1, - }, - ]) + return HttpResponse.json>( + segments + ) } else { new HttpResponse(null, {status: 404}) } diff --git a/frontend/src/client/schemas/DocumentRecordUpdate.ts b/frontend/src/client/schemas/DocumentRecordUpdate.ts index d5e1958..3f1d919 100644 --- a/frontend/src/client/schemas/DocumentRecordUpdate.ts +++ b/frontend/src/client/schemas/DocumentRecordUpdate.ts @@ -3,4 +3,5 @@ export interface DocumentRecordUpdate { target: string approved: boolean | null + update_repetitions: boolean } diff --git a/frontend/src/client/schemas/DocumentRecordUpdateResponse.ts b/frontend/src/client/schemas/DocumentRecordUpdateResponse.ts new file mode 100644 index 0000000..bee1c73 --- /dev/null +++ b/frontend/src/client/schemas/DocumentRecordUpdateResponse.ts @@ -0,0 +1,8 @@ +// This file is autogenerated, do not edit directly. + +export interface DocumentRecordUpdateResponse { + id: number + source: string + target: string + approved: boolean +} diff --git a/frontend/src/client/services/DocumentService.ts b/frontend/src/client/services/DocumentService.ts index 58c949c..a005554 100644 --- a/frontend/src/client/services/DocumentService.ts +++ b/frontend/src/client/services/DocumentService.ts @@ -9,6 +9,7 @@ import {StatusMessage} from '../schemas/StatusMessage' import {DocumentRecord} from '../schemas/DocumentRecord' import {MemorySubstitution} from '../schemas/MemorySubstitution' import {GlossaryRecordSchema} from '../schemas/GlossaryRecordSchema' +import {DocumentRecordUpdateResponse} from '../schemas/DocumentRecordUpdateResponse' import {DocumentRecordUpdate} from '../schemas/DocumentRecordUpdate' import {DocTranslationMemory} from '../schemas/DocTranslationMemory' import {DocTranslationMemoryUpdate} from '../schemas/DocTranslationMemoryUpdate' @@ -39,8 +40,8 @@ export const getRecordSubstitutions = async (doc_id: number, record_id: number): export const getRecordGlossaryRecords = async (doc_id: number, record_id: number): Promise => { return await api.get(`/document/${doc_id}/records/${record_id}/glossary_records`) } -export const updateDocRecord = async (record_id: number, content: DocumentRecordUpdate): Promise => { - return await api.put(`/document/record/${record_id}`, content) +export const updateDocRecord = async (record_id: number, content: DocumentRecordUpdate): Promise => { + return await api.put(`/document/record/${record_id}`, content) } export const getTranslationMemories = async (doc_id: number): Promise => { return await api.get(`/document/${doc_id}/memories`) diff --git a/frontend/src/components/DocSegment.test.ts b/frontend/src/components/DocSegment.test.ts index e0c688b..d48a42a 100644 --- a/frontend/src/components/DocSegment.test.ts +++ b/frontend/src/components/DocSegment.test.ts @@ -27,9 +27,9 @@ describe('DocSegment', () => { }, }) - const dot = wrapper.find('.bg-orange-500') - expect(dot.exists()).toBe(true) - expect(dot.attributes('title')).toBe('Repeated 3 times') + const dot = wrapper.find('.pi') + expect(dot.classes()).not.toContain('opacity-0') + expect(dot.attributes('title')).toContain('Repeated 3 times') }) it('does not show repetition dot when repetitionsCount = 1', () => { @@ -43,8 +43,8 @@ describe('DocSegment', () => { }, }) - const dot = wrapper.find('.bg-orange-500') - expect(dot.exists()).toBe(false) + const dot = wrapper.find('.pi') + expect(dot.classes()).toContain('opacity-0') }) it('does not show repetition dot when not editable', () => { @@ -58,8 +58,8 @@ describe('DocSegment', () => { }, }) - const dot = wrapper.find('.bg-orange-500') - expect(dot.exists()).toBe(false) + const dot = wrapper.find('.pi') + expect(dot.classes()).toContain('opacity-0') }) it('does not show repetition dot when repetitionsCount is undefined', () => { @@ -72,7 +72,7 @@ describe('DocSegment', () => { }, }) - const dot = wrapper.find('.bg-orange-500') - expect(dot.exists()).toBe(false) + const dot = wrapper.find('.pi') + expect(dot.classes()).toContain('opacity-0') }) }) diff --git a/frontend/src/components/DocSegment.vue b/frontend/src/components/DocSegment.vue index dd4115f..9dab351 100644 --- a/frontend/src/components/DocSegment.vue +++ b/frontend/src/components/DocSegment.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 8d5d79b..8ad4cc3 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,7 +1,5 @@ import {createApp} from 'vue' import {createPinia} from 'pinia' -import {defaults} from 'mande' -import {setupWorker} from 'msw/browser' import PrimeVue from 'primevue/config' import {definePreset} from '@primevue/themes' @@ -9,9 +7,7 @@ import Aura from '@primevue/themes/aura' import App from './App.vue' import {getRouter} from './router' -import {mocks} from '../mocks/mocks' -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const themePreset = definePreset(Aura, { semantic: { primary: { @@ -30,9 +26,8 @@ const themePreset = definePreset(Aura, { }, }) -const pinia = createPinia() - const startApp = () => { + const pinia = createPinia() // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const app = createApp(App) app.use(pinia) @@ -58,20 +53,31 @@ const startApp = () => { app.mount('#app') } +if (import.meta.env.PROD) { + startApp() +} + +// Dev server things + +import {defaults} from 'mande' +import {setupWorker} from 'msw/browser' + if (import.meta.env.DEV) { - // to test it locally defaults.credentials = 'include' - const worker = setupWorker(...mocks) - - worker - .start() - .then(() => { - startApp() + const mocksImport = () => import('../mocks/mocks') + mocksImport() + .then((imp) => { + setupWorker(...imp.mocks) + .start() + .then(() => { + startApp() + }) + .catch((e) => { + console.log('MSW init failed', e) + }) }) - .catch((e) => { - console.log('MSW init failed', e) + .catch(() => { + console.log('Failed to load mocks') }) -} else { - startApp() } diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 297f249..8cb8728 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -1,4 +1,4 @@ -import {MandeError} from 'mande' +import {type MandeError} from 'mande' import {createRouter, createWebHistory, RouteRecordRaw} from 'vue-router' const IndexView = () => import('./views/IndexView.vue') diff --git a/frontend/src/stores/current_document.ts b/frontend/src/stores/current_document.ts index 86b04a2..2ddd139 100644 --- a/frontend/src/stores/current_document.ts +++ b/frontend/src/stores/current_document.ts @@ -47,7 +47,12 @@ export const useCurrentDocStore = defineStore('current_document', { (record) => ({...record, loading: false}) ) }, - async updateRecord(record_id: number, content: string, approved: boolean) { + async updateRecord( + record_id: number, + content: string, + approved: boolean, + updateRepeats: boolean + ) { if (!this.document) { return } @@ -61,6 +66,7 @@ export const useCurrentDocStore = defineStore('current_document', { const newRecord = await updateDocRecord(record_id, { target: content, approved: approved, + update_repetitions: updateRepeats, }) this.records[idx] = { ...newRecord, diff --git a/frontend/src/views/DocView.vue b/frontend/src/views/DocView.vue index 73c231c..a39e998 100644 --- a/frontend/src/views/DocView.vue +++ b/frontend/src/views/DocView.vue @@ -78,12 +78,21 @@ const updatePage = async (event: PageState) => { await router.push({query: {page: event.page}}) } -const onSegmentUpdate = async (id: number, text: string, approved: boolean) => { - await store.updateRecord(id, text, approved) +const onSegmentUpdate = async ( + id: number, + text: string, + approved: boolean, + updateRepeats: boolean +) => { + await store.updateRecord(id, text, approved, updateRepeats) } -const onSegmentCommit = async (id: number, text: string) => { - await onSegmentUpdate(id, text, true) +const onSegmentCommit = async ( + id: number, + text: string, + updateRepeats: boolean +) => { + await onSegmentUpdate(id, text, true, updateRepeats) store.focusNextSegment() } @@ -151,10 +160,12 @@ onMounted(async () => { /> -
+