diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index b30fc27d6..e4d30fc48 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -7,12 +7,12 @@ import { Channel } from '../../../shared/channel.interface'; import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants'; import { Playlist, - PlaylistUpdateState + PlaylistUpdateState, } from '../../../shared/playlist.interface'; import { aggregateFavoriteChannels, createFavoritesPlaylist, - createPlaylistObject + createPlaylistObject, } from '../../../shared/playlist.utils'; import { DbStores } from '../indexed-db.config'; import { PlaylistMeta } from '../shared/playlist-meta.type'; @@ -193,9 +193,23 @@ export class PlaylistsService { } getRawPlaylistById(id: string) { - return this.dbService.getByID(DbStores.Playlists, id).pipe(map(playlist => { - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - return playlist.playlist.header.raw + '\n' + playlist.playlist.items.map(item => item.raw).join('\n'); - })); + return this.dbService.getByID(DbStores.Playlists, id).pipe( + map((playlist) => { + return ( + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + playlist.playlist.header.raw + + '\n' + + playlist.playlist.items.map((item) => item.raw).join('\n') + ); + }) + ); + } + + getAllData() { + return this.dbService.getAll(DbStores.Playlists); + } + + removeAll() { + return this.dbService.clear(DbStores.Playlists); } } diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 5561ffbad..2d5144856 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -167,6 +167,59 @@ {{ updateMessage }} + +
+
+ {{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }} +

+ {{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }} +

+
+
+ + +
+
+ +
+
+ {{ 'SETTINGS.REMOVE_ALL' | translate }} +

+ {{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }} +

+
+
+ +
+
{{ 'SETTINGS.EPG_NOTE' | translate }} diff --git a/src/app/settings/settings.component.spec.ts b/src/app/settings/settings.component.spec.ts index aad452803..5e6af8bb8 100644 --- a/src/app/settings/settings.component.spec.ts +++ b/src/app/settings/settings.component.spec.ts @@ -23,7 +23,13 @@ import { TranslatePipe, TranslateService, } from '@ngx-translate/core'; -import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { + MockComponent, + MockModule, + MockPipe, + MockProvider, + MockProviders, +} from 'ng-mocks'; import { of } from 'rxjs'; import { EPG_FORCE_FETCH } from '../../../shared/ipc-commands'; import { DataService } from '../services/data.service'; @@ -35,6 +41,9 @@ import { SettingsComponent } from './settings.component'; import { VideoPlayer } from './settings.interface'; import { Theme } from './theme.enum'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { PlaylistsService } from '../services/playlists.service'; + class MatSnackBarStub { open(): void {} } @@ -80,6 +89,7 @@ describe('SettingsComponent', () => { }, StorageMap, provideMockStore(), + MockProviders(NgxIndexedDBService, PlaylistsService), ], imports: [ HttpClientTestingModule, diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 93ca6d0cd..766b5902f 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -13,10 +13,14 @@ import { TranslateService } from '@ngx-translate/core'; import { take } from 'rxjs'; import * as semver from 'semver'; import { EPG_FORCE_FETCH } from '../../../shared/ipc-commands'; +import { Playlist } from '../../../shared/playlist.interface'; import { DataService } from '../services/data.service'; +import { DialogService } from '../services/dialog.service'; import { EpgService } from '../services/epg.service'; +import { PlaylistsService } from '../services/playlists.service'; import { STORE_KEY } from '../shared/enums/store-keys.enum'; import { SharedModule } from '../shared/shared.module'; +import * as PlaylistActions from '../state/actions'; import { selectIsEpgAvailable } from '../state/selectors'; import { SettingsService } from './../services/settings.service'; import { Language } from './language.enum'; @@ -77,13 +81,15 @@ export class SettingsComponent implements OnInit { * required dependencies into the component */ constructor( - private store: Store, + private dialogService: DialogService, private electronService: DataService, private epgService: EpgService, private formBuilder: UntypedFormBuilder, + private playlistsService: PlaylistsService, private router: Router, private settingsService: SettingsService, private snackBar: MatSnackBar, + private store: Store, private translate: TranslateService ) {} @@ -265,4 +271,85 @@ export class SettingsComponent implements OnInit { this.epgUrl.removeAt(index); this.settingsForm.markAsDirty(); } + + exportData() { + this.playlistsService + .getAllData() + .pipe(take(1)) + .subscribe((data) => { + const blob = new Blob([JSON.stringify(data)], { + type: 'text/plain', + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'playlists.json'; + link.click(); + window.URL.revokeObjectURL(url); + }); + } + + importData() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.addEventListener('change', (event: Event) => { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + + if (file) { + if (file) { + const reader = new FileReader(); + reader.onload = () => { + const contents = reader.result; + + try { + const parsedPlaylists: Playlist[] = JSON.parse( + contents.toString() + ); + + if (!Array.isArray(parsedPlaylists)) { + this.snackBar.open( + this.translate.instant( + 'SETTINGS.IMPORT_ERROR' + ), + null, + { + duration: 2000, + } + ); + } else { + this.store.dispatch( + PlaylistActions.addManyPlaylists({ + playlists: parsedPlaylists, + }) + ); + } + } catch (error) { + this.snackBar.open( + this.translate.instant('SETTINGS.IMPORT_ERROR'), + null, + { + duration: 2000, + } + ); + } + }; + reader.readAsText(file); + } + } + }); + + input.click(); + } + + removeAll() { + this.dialogService.openConfirmDialog({ + title: this.translate.instant('SETTINGS.REMOVE_DIALOG.TITLE'), + message: this.translate.instant('SETTINGS.REMOVE_DIALOG.MESSAGE'), + onConfirm: (): void => + this.store.dispatch(PlaylistActions.removeAllPlaylists()), + }); + } } diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index b9c5e6b40..cfb0ca544 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -115,3 +115,7 @@ export const updatePlaylistPositions = createAction( positionUpdates: { id: string; changes: { position: number } }[]; }>() ); + +export const removeAllPlaylists = createAction( + `${STORE_KEY} Remove all playlists` +); diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index e4346b9b0..fcc0845a2 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import { combineLatestWith, map, switchMap, tap } from 'rxjs/operators'; import { CHANNEL_SET_USER_AGENT, @@ -203,11 +205,32 @@ export class PlaylistEffects { { dispatch: false } ); + removeAll$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.removeAllPlaylists), + switchMap(() => this.playlistsService.removeAll()), + tap(() => { + this.snackBar.open( + this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'), + null, + { + duration: 2000, + } + ); + }) + ); + }, + { dispatch: false } + ); + constructor( private actions$: Actions, private playlistsService: PlaylistsService, private dataService: DataService, + private router: Router, + private snackBar: MatSnackBar, private store: Store, - private router: Router + private translate: TranslateService ) {} } diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index cb99c3b4c..9eeaebe12 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -205,6 +205,12 @@ export const playlistReducer = createReducer( state.playlists ), }; + }), + on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.removeAll(state.playlists), + }; }) ); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b7dc580d3..d1fba431d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -89,7 +89,20 @@ "SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default", "VERSION_DESCRIPTION": "Current version of the application", "EPG_NOTE": "Currently EPG is available only in the electron-based version of IPTVnator.", - "EPG_NOTE_URL_TEXT": "Check it here" + "EPG_NOTE_URL_TEXT": "Check it here", + "IMPORT_EXPORT_DATA": "Import/export playlists", + "IMPORT_EXPORT_DATA_DESCRIPTION": "Export or import playlists in the JSON format used within the application.", + "IMPORT_DATA": "Import", + "EXPORT_DATA": "Export", + "REMOVE_ALL": "Remove playlist", + "REMOVE_ALL_BUTTON": "Remove", + "REMOVE_ALL_DESCRIPTION": "It will remove all existing playlists from the application.", + "REMOVE_DIALOG": { + "TITLE": "Remove all playlists", + "MESSAGE": "Are you sure you want to delete all playlist from the application?" + }, + "IMPORT_ERROR": "Import error, please try with another file.", + "PLAYLISTS_REMOVED": "All playlists were removed." }, "THEMES": { "DARK_THEME": "Dark theme",