diff --git a/src/main/data/apis/pipeline.ts b/src/main/data/apis/pipeline.ts index 76592a7..d0616cb 100644 --- a/src/main/data/apis/pipeline.ts +++ b/src/main/data/apis/pipeline.ts @@ -1,139 +1,5 @@ -import { - aliveXmlToJson, - datatypesXmlToJson, - datatypeXmlToJson, - jobRequestToXml, - jobsXmlToJson, - jobXmlToJson, - scriptsXmlToJson, - scriptXmlToJson, - voicesToJson, - ttsConfigToXml, -} from 'shared/parser/pipelineXmlConverter' -import { - Datatype, - baseurl, - Job, - ResultFile, - Script, - Webservice, - NamedResult, -} from 'shared/types' +import fetch from 'node-fetch' +import { info } from 'electron-log' +import { PipelineAPI } from 'shared/data/apis/pipeline' -import fetch, { Response, RequestInit } from 'node-fetch' - -import { info, error } from 'electron-log' -import { jobResponseXmlToJson } from 'shared/parser/pipelineXmlConverter/jobResponseToJson' -import { selectTtsConfig } from 'shared/data/slices/settings' -import { store } from '../store' - -/** - * Create a fetch function on the pipeline webservice - * for which the resulting pipeline xml is parsed and converted to a js object - * @type T return type of the parser - * @param webserviceUrlBuilder method to build a url, - * optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) - * @param parser method to convert pipeline xml to an object object - * @param options options to be passed to the fetch call - * (like `{method:'POST', body:whateveryoulike}`) - * @returns a customized fetch function from the webservice - * `` (ws:Webservice) => Promise> `` - */ -function createPipelineFetchFunction( - webserviceUrlBuilder: (ws: Webservice) => string, - parser: (text: string) => T, - options?: RequestInit -) { - return (ws?: Webservice) => { - info('fetching ', webserviceUrlBuilder(ws)) - return fetch(webserviceUrlBuilder(ws), options) - .then((response: Response) => response.text()) - .then((text: string) => parser(text)) - } -} - -/** - * Create a simple request on the pipeline webservice - * @param webserviceUrlBuilder method to build a url, optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) - * @param options options to be passed to the fetch call (like `{method:'POST', body:whateveryoulike}`) - * @returns a customized fetch function from the webservice `` (ws:Webservice) => Promise> `` - */ -function createPipelineRequestFunction( - webserviceUrlBuilder: (ws: Webservice) => string, - options?: RequestInit -) { - return (ws?: Webservice) => { - info('request', options.method ?? '', webserviceUrlBuilder(ws)) - return fetch(webserviceUrlBuilder(ws), options) - } -} - -export const pipelineAPI = { - fetchScriptDetails: (s: Script) => - createPipelineFetchFunction( - () => s.href, - (text) => scriptXmlToJson(text) - ), - fetchScripts: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/scripts`, - (text) => scriptsXmlToJson(text) - ), - fetchJobs: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/jobs`, - (text) => jobsXmlToJson(text) - ), - fetchJobData: (j: Job) => - createPipelineFetchFunction( - () => j.jobData.href, - (text) => { - return jobXmlToJson(text) - } - ), - launchJob: (j: Job) => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/jobs`, - (text) => jobResponseXmlToJson(text), - { - method: 'POST', - body: jobRequestToXml({ - ...j.jobRequest, - nicename: - j.jobRequest.nicename || j.jobData.nicename || 'Job', - }), - } - ), - deleteJob: (j: Job) => - createPipelineRequestFunction(() => j.jobData.href, { - method: 'DELETE', - }), - fetchResult: (r: ResultFile | NamedResult) => () => - fetch(r.href) - .then((response) => response.blob()) - .then((blob) => blob.arrayBuffer()), - fetchDatatypeDetails: (d: Datatype) => - createPipelineFetchFunction( - () => d.href, - (text) => datatypeXmlToJson(d.href, d.id, text) - ), - fetchDatatypes: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/datatypes`, - (text) => datatypesXmlToJson(text) - ), - fetchAlive: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/alive`, - (text) => aliveXmlToJson(text) - ), - fetchTtsVoices: () => - createPipelineFetchFunction( - (ws) => `${baseurl(ws)}/voices`, - (text) => voicesToJson(text), - { - method: 'POST', - body: ttsConfigToXml(selectTtsConfig(store.getState())), - } - ), -} +export const pipelineAPI = new PipelineAPI(fetch, info) diff --git a/src/main/data/middlewares/pipeline.ts b/src/main/data/middlewares/pipeline.ts index 4a7e156..8a2d9b5 100644 --- a/src/main/data/middlewares/pipeline.ts +++ b/src/main/data/middlewares/pipeline.ts @@ -46,6 +46,7 @@ import { selectDownloadPath, selectPipelineProperties, selectSettings, + selectTtsConfig, } from 'shared/data/slices/settings' import { ParserException } from 'shared/parser/pipelineXmlConverter/parser' import { PipelineInstance } from 'main/factories' @@ -316,7 +317,9 @@ export function pipelineMiddleware({ getState, dispatch }) { }) .then((datatypes) => { dispatch(setDatatypes(datatypes)) - return pipelineAPI.fetchTtsVoices()(newWebservice) + return pipelineAPI.fetchTtsVoices( + selectTtsConfig(getState()) + )(newWebservice) }) .then((voices: Array) => { // console.log('TTS Voices', voices) diff --git a/src/main/data/middlewares/settings.ts b/src/main/data/middlewares/settings.ts index a394976..0fd33c9 100644 --- a/src/main/data/middlewares/settings.ts +++ b/src/main/data/middlewares/settings.ts @@ -4,7 +4,11 @@ import { info } from 'electron-log' import { existsSync, readFileSync, writeFile } from 'fs' import { resolve } from 'path' import { ENVIRONMENT } from 'shared/constants' -import { save, setAutoCheckUpdate } from 'shared/data/slices/settings' +import { + save, + selectTtsConfig, + setAutoCheckUpdate, +} from 'shared/data/slices/settings' import { checkForUpdate } from 'shared/data/slices/update' import { ttsConfigToXml } from 'shared/parser/pipelineXmlConverter/ttsConfigToXml' import { ApplicationSettings, TtsVoice } from 'shared/types' @@ -12,7 +16,7 @@ import { RootState } from 'shared/types/store' import { resolveUnpacked } from 'shared/utils' import { fileURLToPath, pathToFileURL } from 'url' import { pipelineAPI } from '../apis/pipeline' -import { setTtsVoices } from 'shared/data/slices/pipeline' +import { selectWebservice, setTtsVoices } from 'shared/data/slices/pipeline' const settingsFile = resolve(app.getPath('userData'), 'settings.json') @@ -154,8 +158,8 @@ export function settingsMiddleware({ getState, dispatch }) { ) // re-fetch the /voices endpoint pipelineAPI - .fetchTtsVoices()( - (getState() as RootState).pipeline.webservice + .fetchTtsVoices(selectTtsConfig(getState()))( + selectWebservice(getState()) ) .then((voices: Array) => { console.log('TTS Voices', voices) diff --git a/src/renderer/components/TtsEnginesConfig/index.tsx b/src/renderer/components/TtsEnginesConfig/index.tsx index 4374e5a..cb5dfc8 100644 --- a/src/renderer/components/TtsEnginesConfig/index.tsx +++ b/src/renderer/components/TtsEnginesConfig/index.tsx @@ -3,7 +3,9 @@ Select a script and submit a new job */ import { useState, useEffect } from 'react' import { useWindowStore } from 'renderer/store' -import { TtsConfig, TtsVoice, TtsEngineProperty } from 'shared/types/ttsConfig' +import { PipelineAPI } from 'shared/data/apis/pipeline' +import { selectTtsVoices, setTtsVoices } from 'shared/data/slices/pipeline' +import { TtsVoice } from 'shared/types/ttsConfig' const enginePropertyKeys = [ 'org.daisy.pipeline.tts.azure.key', @@ -15,21 +17,41 @@ const engineNames = { 'org.daisy.pipeline.tts.google': 'Google', } +const pipelineAPI = new PipelineAPI( + (url, ...args) => window.fetch(url, ...args), + console.info +) + +const { App } = window + +// Clone operation to ensure the full array is copied and avoid +// having array of references to object we don't want to change +const clone = (propsArray: Array<{ key: string; value: string }>) => [ + ...propsArray.map((kv) => ({ key: kv.key, value: kv.value })), +] + export function TtsEnginesConfigPane({ ttsEngineProperties, onChangeTtsEngineProperties, }) { const { pipeline } = useWindowStore() console.log('TTS engine props', ttsEngineProperties) - const [engineProperties, setEngineProperties] = useState([ - ...ttsEngineProperties, - ]) + // Clone array and objects in it to avoid updating the oriiginal props + const [engineProperties, setEngineProperties] = useState< + Array<{ key: string; value: string }> + >(clone(ttsEngineProperties)) - // useEffect(() => { + const [engineMessage, setEngineMessage] = useState<{ + [engineKey: string]: string + }>({}) + + const [enginePropsChanged, setEnginePropsChanged] = useState<{ + [engineKey: string]: boolean + }>({}) - // }, []) let onPropertyChange = (e, propName) => { - let engineProperties_ = [...engineProperties] + e.preventDefault() + let engineProperties_ = clone(engineProperties) let prop = engineProperties_.find((prop) => prop.key == propName) if (prop) { prop.value = e.target.value @@ -40,10 +62,148 @@ export function TtsEnginesConfigPane({ } engineProperties_.push(newProp) } + // Search for updates compared to original props + let realProp = (ttsEngineProperties || []).find( + (prop) => prop.key == propName + ) + const engineKey = propName.split('.').slice(0, 5).join('.') + setEngineMessage({ + ...engineMessage, + [engineKey]: null, + }) + setEnginePropsChanged({ + ...enginePropsChanged, + [engineKey]: + realProp == undefined || + (realProp && realProp.value != prop.value), + }) setEngineProperties([...engineProperties_]) - onChangeTtsEngineProperties([...engineProperties_]) } - console.log(engineProperties) + + const isConnectedToTTSEngine = (engineKey: string) => { + return ( + selectTtsVoices(App.store.getState()).filter( + (v) => v.engine == engineKey.split('.').slice(-1)[0] + ).length > 0 + ) + } + + const connectToTTSEngine = (engineKey: string) => { + const ttsProps = [ + ...engineProperties.filter((k) => k.key.startsWith(engineKey)), + ] + // Reset message or error for the engine + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Connecting ...', + }) + pipelineAPI + .fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: ttsProps, + })(pipeline.webservice) + .then((voices: TtsVoice[]) => { + // If any voice of the engine is now available + if ( + voices.filter( + (v) => v.engine == engineKey.split('.').slice(-1)[0] + ).length > 0 + ) { + // Connected, save the tts engine settings + const updatedSettings = [ + ...ttsEngineProperties.filter( + (k) => !k.key.startsWith(engineKey) + ), + ...ttsProps, + ] + // Save back in the complete settings the new settings + onChangeTtsEngineProperties(updatedSettings) + setEnginePropsChanged({ + ...enginePropsChanged, + [engineKey]: false, + }) + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Connected', + }) + // use those new settings to recompute + // the full voices list + return pipelineAPI.fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: updatedSettings, + })(pipeline.webservice) + } else { + // could not connect + // indicate connection error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'Could not connect to engine, please check yout credentials or the service status.', + }) + // and return empty array to not update the voices array + return [] + } + }) + .then((fullVoicesList: TtsVoice[]) => { + // Update the voices array if its not empty + if (fullVoicesList.length > 0) + App.store.dispatch(setTtsVoices(fullVoicesList)) + }) + .catch((e) => { + console.error(e) + // Indicate an error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'An error occured while trying to connect : ' + e, + }) + }) + } + + const disconnectFromTTSEngine = (engineKey: string) => { + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Disconnecting ...', + }) + // Let user disconnect from a TTS engine + // For now, remove the API key setting provided for the engine selected + // (not ideal but not sure how to do it for now) + const updatedSettings = [ + ...ttsEngineProperties.filter( + (kv) => + !(kv.key.startsWith(engineKey) && kv.key.endsWith('key')) + ), + ] + console.log(updatedSettings) + // Save back in the complete settings the new settings + onChangeTtsEngineProperties(updatedSettings) + // use those new settings to recompute + // the full voices list + pipelineAPI + .fetchTtsVoices({ + preferredVoices: [], + ttsEngineProperties: updatedSettings, + })(pipeline.webservice) + .then((fullVoicesList: TtsVoice[]) => { + setEngineMessage({ + ...engineMessage, + [engineKey]: 'Disconnected', + }) + // Update the voices array if its not empty + if (fullVoicesList.length > 0) + App.store.dispatch(setTtsVoices(fullVoicesList)) + }) + .catch((e) => { + console.error(e) + // Indicate an error + setEngineMessage({ + ...engineMessage, + [engineKey]: + 'An error occured while trying to disconnect : ' + e, + }) + }) + } + return ( <>

@@ -95,6 +255,45 @@ export function TtsEnginesConfigPane({ /> ))} + {engineMessage[engineKeyPrefix] && ( +

  • + {engineMessage[engineKeyPrefix]} +
  • + )} + {['azure', 'google'].includes( + engineKeyPrefix.split('.').slice(-1)[0] + ) && ( +
  • + {!isConnectedToTTSEngine(engineKeyPrefix) || + enginePropsChanged[engineKeyPrefix] ? ( + + ) : isConnectedToTTSEngine( + engineKeyPrefix + ) ? ( + + ) : ( + '' + )} +
  • + )} ))} diff --git a/src/shared/data/apis/pipeline.ts b/src/shared/data/apis/pipeline.ts new file mode 100644 index 0000000..4ecc7cc --- /dev/null +++ b/src/shared/data/apis/pipeline.ts @@ -0,0 +1,179 @@ +import { + aliveXmlToJson, + datatypesXmlToJson, + datatypeXmlToJson, + jobRequestToXml, + jobsXmlToJson, + jobXmlToJson, + scriptsXmlToJson, + scriptXmlToJson, + voicesToJson, + ttsConfigToXml, +} from 'shared/parser/pipelineXmlConverter' +import { + Datatype, + baseurl, + Job, + ResultFile, + Script, + Webservice, + NamedResult, + TtsConfig, +} from 'shared/types' + +import { jobResponseXmlToJson } from 'shared/parser/pipelineXmlConverter/jobResponseToJson' + +//import fetch, { Response, RequestInit } from 'node-fetch' +//import { info, error } from 'electron-log' + +interface Response { + text: () => Promise + blob: () => Promise<{ + arrayBuffer: () => Promise + }> + status?: number + statusText?: string +} + +interface RequestInit { + method?: string + body?: {} +} +/** + * PipelineAPI class to fetch data from the webserver. + * + * A fetch function is passed in constructor to allow the use in both + * view and application side + */ +export class PipelineAPI { + fetchFunc: (url: string, options?: RequestInit) => Promise + info: (message?: any, ...optionalParams: any[]) => void + constructor(fetchFunc, info?) { + this.fetchFunc = fetchFunc + this.info = info ?? console.info + } + /** + * Create a fetch function on the pipeline webservice + * for which the resulting pipeline xml is parsed and converted to a js object + * @type T return type of the parser + * @param webserviceUrlBuilder method to build a url, + * optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) + * @param parser method to convert pipeline xml to an object object + * @param options options to be passed to the fetch call + * (like `{method:'POST', body:whateveryoulike}`) + * @returns a customized fetch function from the webservice + * `` (ws:Webservice) => Promise> `` + */ + createPipelineFetchFunction( + webserviceUrlBuilder: (ws: Webservice) => string, + parser: (text: string) => T, + options?: RequestInit + ) { + return (ws?: Webservice) => { + this.info( + 'fetching ', + webserviceUrlBuilder(ws), + JSON.stringify(options) + ) + return this.fetchFunc(webserviceUrlBuilder(ws), options) + .then((response: Response) => response.text()) + .then((text: string) => parser(text)) + } + } + + /** + * Create a simple request on the pipeline webservice + * @param webserviceUrlBuilder method to build a url, optionnaly using a webservice (like ``(ws) => `${baseurl(ws)}/scripts` ``) + * @param options options to be passed to the fetch call (like `{method:'POST', body:whateveryoulike}`) + * @returns a customized fetch function from the webservice `` (ws:Webservice) => Promise> `` + */ + createPipelineRequestFunction( + webserviceUrlBuilder: (ws: Webservice) => string, + options?: RequestInit + ) { + return (ws?: Webservice) => { + this.info('request', options.method ?? '', webserviceUrlBuilder(ws)) + return this.fetchFunc(webserviceUrlBuilder(ws), options) + } + } + + fetchScriptDetails(s: Script) { + return this.createPipelineFetchFunction( + () => s.href, + (text) => scriptXmlToJson(text) + ) + } + fetchScripts() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/scripts`, + (text) => scriptsXmlToJson(text) + ) + } + fetchJobs() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/jobs`, + (text) => jobsXmlToJson(text) + ) + } + fetchJobData(j: Job) { + return this.createPipelineFetchFunction( + () => j.jobData.href, + (text) => { + return jobXmlToJson(text) + } + ) + } + launchJob(j: Job) { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/jobs`, + (text) => jobResponseXmlToJson(text), + { + method: 'POST', + body: jobRequestToXml({ + ...j.jobRequest, + nicename: + j.jobRequest.nicename || j.jobData.nicename || 'Job', + }), + } + ) + } + deleteJob(j: Job) { + return this.createPipelineRequestFunction(() => j.jobData.href, { + method: 'DELETE', + }) + } + fetchResult(r: ResultFile | NamedResult): () => Promise { + return () => + this.fetchFunc(r.href) + .then((response) => response.blob()) + .then((blob) => blob.arrayBuffer()) + } + fetchDatatypeDetails(d: Datatype) { + return this.createPipelineFetchFunction( + () => d.href, + (text) => datatypeXmlToJson(d.href, d.id, text) + ) + } + fetchDatatypes() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/datatypes`, + (text) => datatypesXmlToJson(text) + ) + } + fetchAlive() { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/alive`, + (text) => aliveXmlToJson(text) + ) + } + fetchTtsVoices(ttsConfig: TtsConfig) { + return this.createPipelineFetchFunction( + (ws) => `${baseurl(ws)}/voices`, + (text) => voicesToJson(text), + { + method: 'POST', + body: ttsConfigToXml(ttsConfig), + } + ) + } +} diff --git a/src/shared/data/slices/pipeline.ts b/src/shared/data/slices/pipeline.ts index 6860ce7..2093c50 100644 --- a/src/shared/data/slices/pipeline.ts +++ b/src/shared/data/slices/pipeline.ts @@ -393,4 +393,5 @@ export const { selectDatatypes, newJob, prepareJobRequest, + selectTtsVoices, } = selectors