From 5d5b67a0691baa60cd192ce6f7dcbaf5fd3b7fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Nie=C5=82acny?= Date: Fri, 19 Dec 2025 10:27:41 +0000 Subject: [PATCH] Add ChaptersCueImportModal for importing chapters from .cue files - Implemented a new modal component for importing chapters from .cue files, allowing users to select and preview chapters. - Added functionality to parse .cue files and display chapter information, including start times and titles. - Integrated the modal into the chapters page, providing a button to open the import modal. - Updated localization strings to support new UI elements and messages related to cue file import. - Added Cypress tests to ensure correct behavior when importing valid and invalid cue files. --- .../modals/ChaptersCueImportModal.vue | 206 ++++++++++++++++++ .../components/pages/ChaptersCueImport.cy.js | 157 +++++++++++++ client/pages/audiobook/_id/chapters.vue | 45 +++- client/strings/en-us.json | 6 + 4 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 client/components/modals/ChaptersCueImportModal.vue create mode 100644 client/cypress/tests/components/pages/ChaptersCueImport.cy.js diff --git a/client/components/modals/ChaptersCueImportModal.vue b/client/components/modals/ChaptersCueImportModal.vue new file mode 100644 index 000000000..fd4e3a07a --- /dev/null +++ b/client/components/modals/ChaptersCueImportModal.vue @@ -0,0 +1,206 @@ + + + diff --git a/client/cypress/tests/components/pages/ChaptersCueImport.cy.js b/client/cypress/tests/components/pages/ChaptersCueImport.cy.js new file mode 100644 index 000000000..70844de97 --- /dev/null +++ b/client/cypress/tests/components/pages/ChaptersCueImport.cy.js @@ -0,0 +1,157 @@ +import ChaptersCueImportModal from '@/components/modals/ChaptersCueImportModal.vue' + +const cueStrings = { + HeaderImportCue: 'Import .cue', + MessageCueSelectFile: 'Select a .cue file to preview chapters', + LabelChaptersFound: 'Chapters found', + LabelStart: 'Start', + LabelTitle: 'Title', + ButtonCancel: 'Cancel', + ButtonApplyChapters: 'Apply Chapters', + ToastCueParseFailed: 'Failed to parse .cue file', + MessageCueNoChaptersFound: 'No chapters found in .cue file' +} + +function buildMountOptions(onApply, props = {}, toastError = null) { + const stubs = { + 'ui-btn': { + template: '' + }, + 'modals-modal': { + props: ['value'], + template: '
' + } + } + + const mocks = { + $store: { + state: { + streamLibraryItem: null + }, + getters: { + 'user/getToken': 'token' + }, + commit: () => {}, + dispatch: () => {} + }, + $toast: { + error: toastError || (() => {}), + info: () => {}, + success: () => {}, + warning: () => {} + }, + $strings: cueStrings, + $getString: (value) => value + } + + return { + stubs, + mocks, + propsData: { + value: true, + ...props + }, + listeners: { + apply: onApply + } + } +} + +describe('Chapters cue import', () => { + it('imports chapters from a cue file', () => { + const cueText = [ + 'PERFORMER "Author"', + 'TITLE "Test Book"', + 'FILE "test.mp3" MP3', + ' TRACK 01 AUDIO', + ' TITLE "Intro"', + ' INDEX 01 00:00:00', + ' TRACK 02 AUDIO', + ' TITLE "Chapter 1"', + ' INDEX 01 00:10:00', + ' TRACK 03 AUDIO', + ' INDEX 01 00:20:00' + ].join('\n') + + const onApply = cy.spy().as('onApply') + const cueFile = new File([cueText], 'chapters.cue', { type: 'text/plain' }) + cy.mount(ChaptersCueImportModal, buildMountOptions(onApply)).then(({ wrapper }) => { + wrapper.setProps({ cueFile }) + }) + + cy.contains('Intro').should('be.visible') + cy.contains('Chapter 1').should('be.visible') + cy.contains('Track 3').should('be.visible') + + cy.contains(cueStrings.ButtonApplyChapters).click() + + cy.get('@onApply').should('have.been.calledOnce') + cy.get('@onApply') + .its('firstCall.args.0') + .should((chapters) => { + expect(chapters).to.have.length(3) + expect(chapters[0]).to.include({ title: 'Intro' }) + expect(chapters[1]).to.include({ title: 'Chapter 1' }) + expect(chapters[2]).to.include({ title: 'Track 3' }) + }) + }) + + it('shows an error for invalid cue content', () => { + const onApply = cy.spy().as('onApply') + const toastError = cy.spy().as('toastError') + const cueFile = new File([''], 'invalid.cue', { type: 'text/plain' }) + + cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => { + wrapper.setProps({ cueFile }) + }) + + cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible') + cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled') + cy.get('@onApply').should('not.have.been.called') + cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed) + }) + + it('shows an error for malformed cue content', () => { + const cueText = [ + 'FILE "test.mp3" MP3', + ' TRACK 01 AUDIO', + ' TITLE "Intro"', + ' INDEX 01 00:00:AA' + ].join('\n') + + const onApply = cy.spy().as('onApply') + const toastError = cy.spy().as('toastError') + const cueFile = new File([cueText], 'malformed.cue', { type: 'text/plain' }) + + cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => { + wrapper.setProps({ cueFile }) + }) + + cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible') + cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled') + cy.get('@onApply').should('not.have.been.called') + cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed) + }) + + it('shows an error for non-cue file content', () => { + const cueText = [ + '{ "not": "a cue file" }', + 'lorem ipsum', + 'just some text' + ].join('\n') + + const onApply = cy.spy().as('onApply') + const toastError = cy.spy().as('toastError') + const cueFile = new File([cueText], 'random.txt', { type: 'text/plain' }) + + cy.mount(ChaptersCueImportModal, buildMountOptions(onApply, {}, toastError)).then(({ wrapper }) => { + wrapper.setProps({ cueFile }) + }) + + cy.contains(cueStrings.MessageCueNoChaptersFound).should('be.visible') + cy.contains(cueStrings.ButtonApplyChapters).should('be.disabled') + cy.get('@onApply').should('not.have.been.called') + cy.get('@toastError').should('have.been.calledOnceWith', cueStrings.ToastCueParseFailed) + }) + +}) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index e91a8846d..2e35626f8 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -26,6 +26,7 @@ {{ $strings.ButtonRemoveAll }} {{ $strings.ButtonShiftTimes }} {{ $strings.ButtonLookup }} + {{ $strings.ButtonImportCue }}
{{ $strings.ButtonReset }} {{ $strings.ButtonSave }} @@ -262,6 +263,9 @@
+ + +