diff --git a/internal/fakeapi/data.go b/internal/fakeapi/data.go index 0ca941f..437e43a 100644 --- a/internal/fakeapi/data.go +++ b/internal/fakeapi/data.go @@ -1,6 +1,7 @@ package fakeapi import ( + "net/url" "strings" "testing" @@ -9,7 +10,17 @@ import ( ) var ( - SoundURL = "http://audiocdn.lingualeo.com/v2/3/102085-631152000.mp3" + SoundURL = "http://audiocdn.lingualeo.com/v2/3/102085-631152000.mp3" + u1, _ = url.Parse("http://contentcdn.lingualeo.com/uploads/picture/31064.png") + u2, _ = url.Parse("http://contentcdn.lingualeo.com/uploads/picture/335521.png") + u3, _ = url.Parse("http://contentcdn.lingualeo.com/uploads/picture/374830.png") + u4, _ = url.Parse("http://contentcdn.lingualeo.com/uploads/picture/620779.png") + PictureUrls = []*url.URL{ + u1, + u2, + u3, + u4, + } ResponseData = []byte(`{"error_msg":"","translate_source":"base","is_user":0, "word_forms":[{"word":"accommodation","type":"прил."}], "pic_url":"http:\/\/contentcdn.lingualeo.com\/uploads\/picture\/3589594.png", diff --git a/internal/files/download.go b/internal/files/download.go index f4a8835..07d9010 100644 --- a/internal/files/download.go +++ b/internal/files/download.go @@ -28,8 +28,8 @@ func (f File) GetIndex() int { // FileDownloader structure type FileDownloader struct{} -// NewFileDownloader initialize new file downloader -func NewFileDownloader() *FileDownloader { +// New initialize new file downloader +func New() *FileDownloader { return &FileDownloader{} } @@ -77,3 +77,7 @@ func (f *FileDownloader) Download(url string) (string, error) { } return filename, nil } + +func (f *FileDownloader) Remove(path string) error { + return os.Remove(path) +} diff --git a/internal/player/player.go b/internal/player/player.go new file mode 100644 index 0000000..7d47344 --- /dev/null +++ b/internal/player/player.go @@ -0,0 +1,37 @@ +package player + +import ( + "os/exec" + "strings" +) + +const ( + separator = " " +) + +type Player struct { + player string + params []string +} + +func New(player string) Player { + parts := strings.Split(player, separator) + playerExec := parts[0] + params := parts[1:] + return Player{ + params: params, + player: playerExec, + } +} + +func (p Player) Play(url string) error { + params := append(p.params[:len(p.params):len(p.params)], url) + cmd := exec.Command(p.player, params...) + if err := cmd.Start(); err != nil { + return err + } + if err := cmd.Wait(); err != nil { + return err + } + return nil +} diff --git a/internal/visualizer/browser/browser.go b/internal/visualizer/browser/browser.go new file mode 100644 index 0000000..1d6f052 --- /dev/null +++ b/internal/visualizer/browser/browser.go @@ -0,0 +1,33 @@ +package browser + +import ( + "net/url" + "os/exec" + "runtime" +) + +func open(u *url.URL) error { + var cmd string + var args []string + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, u.String()) + return exec.Command(cmd, args...).Start() +} + +type Visualizer func(u *url.URL) error + +func (v Visualizer) Show(u *url.URL) error { + return v(u) +} + +func New() Visualizer { + return Visualizer(open) +} diff --git a/pkg/translator/args.go b/pkg/translator/args.go index 20d44ef..32cb389 100644 --- a/pkg/translator/args.go +++ b/pkg/translator/args.go @@ -180,6 +180,12 @@ func prepareArgs(version string) (Lingualeo, error) { Usage: "Pronounce words", Destination: &args.Sound, }, + &cli.BoolFlag{ + Name: "picture", + Aliases: []string{"pc"}, + Usage: "Open translate pictures", + Destination: &args.Picture, + }, &cli.BoolFlag{ Name: "download", Aliases: []string{"dl"}, @@ -339,6 +345,9 @@ func (args *Lingualeo) mergeConfigs(a *Lingualeo) { if a.Sound { args.Sound = a.Sound } + if a.Picture { + args.Picture = a.Picture + } if a.DownloadSoundFile { args.DownloadSoundFile = a.DownloadSoundFile } diff --git a/pkg/translator/download.go b/pkg/translator/download.go index 5252f8a..79c28a5 100644 --- a/pkg/translator/download.go +++ b/pkg/translator/download.go @@ -13,10 +13,11 @@ import ( //go:generate mockery type Downloader interface { Download(url string) (string, error) + Remove(path string) error } -// DownloadFiles download files from URLs channel -func DownloadFiles(ctx context.Context, urls <-chan string, downloader Downloader) <-chan files.File { +// downloadFiles download files from URLs channel +func downloadFiles(ctx context.Context, urls <-chan string, downloader Downloader) <-chan files.File { out := make(chan files.File) var wg sync.WaitGroup wg.Add(1) diff --git a/pkg/translator/download_file_test.go b/pkg/translator/download_file_test.go index bc22d76..bf7a271 100644 --- a/pkg/translator/download_file_test.go +++ b/pkg/translator/download_file_test.go @@ -21,7 +21,7 @@ func TestDownloadWordFile(t *testing.T) { close(inChan) - out := DownloadFiles(ctx, inChan, downloader) + out := downloadFiles(ctx, inChan, downloader) fileName := (<-out).Filename assert.Equal(t, fileName, testFile) } diff --git a/pkg/translator/get_word_test.go b/pkg/translator/get_word_test.go index c8c00f1..1e7cc15 100644 --- a/pkg/translator/get_word_test.go +++ b/pkg/translator/get_word_test.go @@ -3,7 +3,6 @@ package translator import ( "context" "log/slog" - "sync" "testing" "github.com/trezorg/lingualeo/internal/logger" @@ -23,11 +22,17 @@ func TestProcessTranslationResponseJson(t *testing.T) { testFile := "/tmp/test.file" count := 1000 // max for race checking translator := NewMock_Translator(t) - + player := NewMock_Pronouncer(t) + visualizer := NewMock_Visualizer(t) res := translateWordResult(fakeapi.SearchWord) downloader.EXPECT().Download(fakeapi.SoundURL).Return(testFile, nil).Times(count) + downloader.EXPECT().Remove(testFile).Return(nil).Times(count) translator.EXPECT().TranslateWord(fakeapi.SearchWord).Return(res).Times(count) + player.EXPECT().Play(testFile).Return(nil).Times(count) + for _, u := range fakeapi.PictureUrls { + visualizer.EXPECT().Show(u).Return(nil).Times(count) + } logger.Prepare(slog.LevelError + 10) searchWords := make([]string, 0, count) @@ -37,18 +42,21 @@ func TestProcessTranslationResponseJson(t *testing.T) { } ctx := context.Background() - args := Lingualeo{Sound: true, Words: searchWords, Add: false, Translator: translator} - var wg sync.WaitGroup - - wg.Add(1) - soundChan, _, resultChan := args.Process(ctx, &wg) - wg.Add(1) + args := Lingualeo{ + Sound: true, + Words: searchWords, + Add: false, + Picture: true, + DownloadSoundFile: true, + Translator: translator, + Downloader: downloader, + Pronouncer: player, + Visualizer: visualizer, + } - go args.downloadAndPronounce(ctx, soundChan, &wg, downloader) + ch := args.translateToChan(ctx) - for result := range resultChan { + for result := range ch { fakeapi.CheckResult(t, result, searchWords[0], fakeapi.Expected) } - - wg.Wait() } diff --git a/pkg/translator/helpers.go b/pkg/translator/helpers.go index 529e352..ff0370a 100644 --- a/pkg/translator/helpers.go +++ b/pkg/translator/helpers.go @@ -1,33 +1,9 @@ package translator import ( - "os/exec" - "strings" "unicode" ) -func PlaySound(player string, url string) error { - parts := strings.Split(player, " ") - playerExec := parts[0] - params := append(parts[1:], url) - cmd := exec.Command(playerExec, params...) - err := cmd.Start() - if err != nil { - return err - } - err = cmd.Wait() - if err != nil { - return err - } - return nil -} - -func isCommandAvailable(name string) bool { - execName := strings.Split(name, " ")[0] - _, err := exec.LookPath(execName) - return err == nil -} - func isRussianWord(s string) bool { for _, symbol := range s { if !unicode.Is(unicode.Cyrillic, symbol) && !unicode.Is(unicode.Number, symbol) { diff --git a/pkg/translator/linguleo.go b/pkg/translator/linguleo.go index a9e58f8..3c9b128 100644 --- a/pkg/translator/linguleo.go +++ b/pkg/translator/linguleo.go @@ -3,15 +3,17 @@ package translator import ( "context" "log/slog" - "os" + "net/url" "strings" "sync" "github.com/trezorg/lingualeo/internal/files" + "github.com/trezorg/lingualeo/internal/visualizer/browser" "github.com/trezorg/lingualeo/pkg/api" "github.com/trezorg/lingualeo/pkg/channel" "github.com/trezorg/lingualeo/internal/logger" + "github.com/trezorg/lingualeo/internal/player" "github.com/trezorg/lingualeo/pkg/messages" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -45,7 +47,10 @@ type Translator interface { } type Lingualeo struct { - Translator Translator + Translator + Downloader + Pronouncer + Visualizer LogLevel string `yaml:"log_level" json:"log_level" toml:"log_level"` Password string `yaml:"password" json:"password" toml:"password"` Config string @@ -55,6 +60,7 @@ type Lingualeo struct { Translation []string Add bool `yaml:"add" json:"add" toml:"add"` Sound bool `yaml:"sound" json:"sound" toml:"sound"` + Picture bool `yaml:"picture" json:"picture" toml:"picture"` Debug bool `yaml:"debug" json:"debug" toml:"debug"` DownloadSoundFile bool `yaml:"download" json:"download" toml:"download"` LogPrettyPrint bool `yaml:"log_pretty_print" json:"log_pretty_print" toml:"log_pretty_print"` @@ -94,6 +100,9 @@ func New(version string) (Lingualeo, error) { if err != nil { return client, err } + client.Pronouncer = player.New(client.Player) + client.Downloader = files.New() + client.Visualizer = browser.New() return client, nil } @@ -119,6 +128,28 @@ func translateWords(ctx context.Context, translator Translator, results <-chan s return out } +// visualizeWords visualize words from URL channel +func visualizeWords(ctx context.Context, visualizer Visualizer, results <-chan *url.URL) <-chan error { + out := make(chan error) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for u := range channel.OrDone(ctx, results) { + wg.Add(1) + go func(u *url.URL) { + defer wg.Done() + out <- visualizer.Show(u) + }(u) + } + }() + go func() { + defer close(out) + wg.Wait() + }() + return out +} + // addWords add words func addWords(ctx context.Context, translator Translator, results <-chan api.Result) <-chan api.OperationResult { out := make(chan api.OperationResult) @@ -187,9 +218,9 @@ func (args *Lingualeo) prepareResultToAdd(result *api.Result) bool { return false } -func (args *Lingualeo) downloadAndPronounce(ctx context.Context, urls <-chan string, wg *sync.WaitGroup, downloader Downloader) { +func (args *Lingualeo) downloadAndPronounce(ctx context.Context, urls <-chan string, wg *sync.WaitGroup) { defer wg.Done() - fileChannel := files.OrderedChannel(DownloadFiles(ctx, urls, downloader), len(urls)) + fileChannel := files.OrderedChannel(downloadFiles(ctx, urls, args.Downloader), len(urls)) for res := range channel.OrDone(ctx, fileChannel) { if res.Error != nil { slog.Error("cannot download", "error", res.Error) @@ -198,12 +229,10 @@ func (args *Lingualeo) downloadAndPronounce(ctx context.Context, urls <-chan str if res.Filename == "" { continue } - err := PlaySound(args.Player, res.Filename) - if err != nil { + if err := args.Play(res.Filename); err != nil { slog.Error("cannot play filename", "filename", res.Filename, "error", err) } - err = os.Remove(res.Filename) - if err != nil { + if err := args.Downloader.Remove(res.Filename); err != nil { slog.Error("cannot remove filename", "filename", res.Filename, "error", err) } } @@ -212,7 +241,7 @@ func (args *Lingualeo) downloadAndPronounce(ctx context.Context, urls <-chan str func (args *Lingualeo) pronounce(ctx context.Context, urls <-chan string, wg *sync.WaitGroup) { defer wg.Done() for url := range channel.OrDone(ctx, urls) { - err := PlaySound(args.Player, url) + err := args.Play(url) if err != nil { slog.Error("cannot play url", "url", url, "error", err) } @@ -222,7 +251,7 @@ func (args *Lingualeo) pronounce(ctx context.Context, urls <-chan string, wg *sy // Pronounce downloads and pronounce words func (args *Lingualeo) Pronounce(ctx context.Context, urls <-chan string, wg *sync.WaitGroup) { if args.DownloadSoundFile { - args.downloadAndPronounce(ctx, urls, wg, &files.FileDownloader{}) + args.downloadAndPronounce(ctx, urls, wg) } else { args.pronounce(ctx, urls, wg) } @@ -241,9 +270,28 @@ func (args *Lingualeo) AddToDictionary(ctx context.Context, resultsToAdd <-chan } } +// Visualize show words prictures +func (args *Lingualeo) Visualize(ctx context.Context, urls <-chan *url.URL, wg *sync.WaitGroup) { + defer wg.Done() + ch := visualizeWords(ctx, args.Visualizer, urls) + for err := range ch { + if err != nil { + slog.Error("cannot show word picture", "error", err) + } + } +} + +type Channels struct { + sound <-chan string + visualize <-chan *url.URL + add <-chan api.Result + results <-chan api.Result +} + // Process starts translation process -func (args *Lingualeo) Process(ctx context.Context, wg *sync.WaitGroup) (<-chan string, <-chan api.Result, <-chan api.Result) { +func (args *Lingualeo) Process(ctx context.Context, wg *sync.WaitGroup) Channels { soundChan := make(chan string, len(args.Words)) + visualizeChan := make(chan *url.URL, len(args.Words)) addWordChan := make(chan api.Result, len(args.Translation)) resultsChan := make(chan api.Result, len(args.Words)) @@ -251,6 +299,7 @@ func (args *Lingualeo) Process(ctx context.Context, wg *sync.WaitGroup) (<-chan defer func() { wg.Done() close(soundChan) + close(visualizeChan) close(addWordChan) close(resultsChan) }() @@ -263,6 +312,19 @@ func (args *Lingualeo) Process(ctx context.Context, wg *sync.WaitGroup) (<-chan if args.Sound { soundChan <- result.Result.SoundURL } + if args.Picture { + for _, p := range result.Result.Translate { + if p.Picture == "" { + continue + } + url, err := url.Parse(p.Picture) + if err != nil { + slog.Error("cannot parse picture url", "url", p.Picture, "error", err) + continue + } + visualizeChan <- url + } + } if args.Add { if resultsToAdd := args.prepareResultToAdd(&result.Result); resultsToAdd { @@ -273,7 +335,12 @@ func (args *Lingualeo) Process(ctx context.Context, wg *sync.WaitGroup) (<-chan } }() - return soundChan, addWordChan, resultsChan + return Channels{ + sound: soundChan, + visualize: visualizeChan, + add: addWordChan, + results: resultsChan, + } } type processResult func(api.Result) error @@ -286,21 +353,25 @@ func ProcessResultImpl(result api.Result) error { func (args *Lingualeo) translateToChan(ctx context.Context) <-chan api.Result { var wg sync.WaitGroup wg.Add(1) - soundChan, addWordChan, resultsChan := args.Process(ctx, &wg) + channels := args.Process(ctx, &wg) if args.Sound { wg.Add(1) - go args.Pronounce(ctx, soundChan, &wg) + go args.Pronounce(ctx, channels.sound, &wg) } if args.Add { wg.Add(1) - go args.AddToDictionary(ctx, addWordChan, &wg) + go args.AddToDictionary(ctx, channels.add, &wg) + } + if args.Picture { + wg.Add(1) + go args.Visualize(ctx, channels.visualize, &wg) } ch := make(chan api.Result, len(args.Words)) go func() { defer close(ch) - for result := range channel.OrDone(ctx, resultsChan) { + for result := range channel.OrDone(ctx, channels.results) { ch <- result } wg.Wait() diff --git a/pkg/translator/mock_Downloader.go b/pkg/translator/mock_Downloader.go index 2ce77b9..7517701 100644 --- a/pkg/translator/mock_Downloader.go +++ b/pkg/translator/mock_Downloader.go @@ -73,6 +73,52 @@ func (_c *Mock_Downloader_Download_Call) RunAndReturn(run func(string) (string, return _c } +// Remove provides a mock function with given fields: path +func (_m *Mock_Downloader) Remove(path string) error { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for Remove") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(path) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Mock_Downloader_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' +type Mock_Downloader_Remove_Call struct { + *mock.Call +} + +// Remove is a helper method to define mock.On call +// - path string +func (_e *Mock_Downloader_Expecter) Remove(path interface{}) *Mock_Downloader_Remove_Call { + return &Mock_Downloader_Remove_Call{Call: _e.mock.On("Remove", path)} +} + +func (_c *Mock_Downloader_Remove_Call) Run(run func(path string)) *Mock_Downloader_Remove_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Mock_Downloader_Remove_Call) Return(_a0 error) *Mock_Downloader_Remove_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Mock_Downloader_Remove_Call) RunAndReturn(run func(string) error) *Mock_Downloader_Remove_Call { + _c.Call.Return(run) + return _c +} + // NewMock_Downloader creates a new instance of Mock_Downloader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMock_Downloader(t interface { diff --git a/pkg/translator/mock_Pronouncer.go b/pkg/translator/mock_Pronouncer.go new file mode 100644 index 0000000..10e0e6c --- /dev/null +++ b/pkg/translator/mock_Pronouncer.go @@ -0,0 +1,78 @@ +// Code generated by mockery v2.43.0. DO NOT EDIT. + +package translator + +import mock "github.com/stretchr/testify/mock" + +// Mock_Pronouncer is an autogenerated mock type for the Pronouncer type +type Mock_Pronouncer struct { + mock.Mock +} + +type Mock_Pronouncer_Expecter struct { + mock *mock.Mock +} + +func (_m *Mock_Pronouncer) EXPECT() *Mock_Pronouncer_Expecter { + return &Mock_Pronouncer_Expecter{mock: &_m.Mock} +} + +// Play provides a mock function with given fields: url +func (_m *Mock_Pronouncer) Play(url string) error { + ret := _m.Called(url) + + if len(ret) == 0 { + panic("no return value specified for Play") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(url) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Mock_Pronouncer_Play_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Play' +type Mock_Pronouncer_Play_Call struct { + *mock.Call +} + +// Play is a helper method to define mock.On call +// - url string +func (_e *Mock_Pronouncer_Expecter) Play(url interface{}) *Mock_Pronouncer_Play_Call { + return &Mock_Pronouncer_Play_Call{Call: _e.mock.On("Play", url)} +} + +func (_c *Mock_Pronouncer_Play_Call) Run(run func(url string)) *Mock_Pronouncer_Play_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Mock_Pronouncer_Play_Call) Return(_a0 error) *Mock_Pronouncer_Play_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Mock_Pronouncer_Play_Call) RunAndReturn(run func(string) error) *Mock_Pronouncer_Play_Call { + _c.Call.Return(run) + return _c +} + +// NewMock_Pronouncer creates a new instance of Mock_Pronouncer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMock_Pronouncer(t interface { + mock.TestingT + Cleanup(func()) +}) *Mock_Pronouncer { + mock := &Mock_Pronouncer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/translator/mock_Visualizer.go b/pkg/translator/mock_Visualizer.go new file mode 100644 index 0000000..974569e --- /dev/null +++ b/pkg/translator/mock_Visualizer.go @@ -0,0 +1,82 @@ +// Code generated by mockery v2.43.0. DO NOT EDIT. + +package translator + +import ( + url "net/url" + + mock "github.com/stretchr/testify/mock" +) + +// Mock_Visualizer is an autogenerated mock type for the Visualizer type +type Mock_Visualizer struct { + mock.Mock +} + +type Mock_Visualizer_Expecter struct { + mock *mock.Mock +} + +func (_m *Mock_Visualizer) EXPECT() *Mock_Visualizer_Expecter { + return &Mock_Visualizer_Expecter{mock: &_m.Mock} +} + +// Show provides a mock function with given fields: u +func (_m *Mock_Visualizer) Show(u *url.URL) error { + ret := _m.Called(u) + + if len(ret) == 0 { + panic("no return value specified for Show") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*url.URL) error); ok { + r0 = rf(u) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Mock_Visualizer_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show' +type Mock_Visualizer_Show_Call struct { + *mock.Call +} + +// Show is a helper method to define mock.On call +// - u *url.URL +func (_e *Mock_Visualizer_Expecter) Show(u interface{}) *Mock_Visualizer_Show_Call { + return &Mock_Visualizer_Show_Call{Call: _e.mock.On("Show", u)} +} + +func (_c *Mock_Visualizer_Show_Call) Run(run func(u *url.URL)) *Mock_Visualizer_Show_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*url.URL)) + }) + return _c +} + +func (_c *Mock_Visualizer_Show_Call) Return(_a0 error) *Mock_Visualizer_Show_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Mock_Visualizer_Show_Call) RunAndReturn(run func(*url.URL) error) *Mock_Visualizer_Show_Call { + _c.Call.Return(run) + return _c +} + +// NewMock_Visualizer creates a new instance of Mock_Visualizer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMock_Visualizer(t interface { + mock.TestingT + Cleanup(func()) +}) *Mock_Visualizer { + mock := &Mock_Visualizer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/translator/pronounce.go b/pkg/translator/pronounce.go new file mode 100644 index 0000000..57049f4 --- /dev/null +++ b/pkg/translator/pronounce.go @@ -0,0 +1,19 @@ +package translator + +import ( + "os/exec" + "strings" +) + +// Pronouncer interface +// +//go:generate mockery +type Pronouncer interface { + Play(url string) error +} + +func isCommandAvailable(name string) bool { + execName := strings.Split(name, " ")[0] + _, err := exec.LookPath(execName) + return err == nil +} diff --git a/pkg/translator/visualizer.go b/pkg/translator/visualizer.go new file mode 100644 index 0000000..6dba04d --- /dev/null +++ b/pkg/translator/visualizer.go @@ -0,0 +1,10 @@ +package translator + +import "net/url" + +// Visualizer interface +// +//go:generate mockery +type Visualizer interface { + Show(u *url.URL) error +}