diff --git a/Public API v1.yaml b/Public API v1.yaml index 912fd3d..dbd362f 100644 --- a/Public API v1.yaml +++ b/Public API v1.yaml @@ -578,18 +578,18 @@ paths: description: If the archive dir is not found, 404 is sent as a response security: - Auth query parameter: [] - /api/deleteArchiveItem: + /api/deleteArchiveItems: post: tags: - archive summary: Delete item from archive description: 'Deletes an item from the archive' - operationId: post-api-deleteArchiveItem + operationId: post-api-deleteArchiveItems requestBody: content: application/json: schema: - $ref: '#/components/schemas/DeleteArchiveItemRequest' + $ref: '#/components/schemas/DeleteArchiveItemsRequest' responses: '200': description: OK @@ -608,7 +608,7 @@ paths: operationId: post-api-importArchive requestBody: content: - multipart/form-data: + application/json: schema: $ref: '#/components/schemas/ImportArchiveRequest' responses: @@ -2178,21 +2178,15 @@ components: type: number uid: type: string - DeleteArchiveItemRequest: + DeleteArchiveItemsRequest: type: object required: - - extractor - - id - - type + - archives properties: - extractor: - type: string - id: - type: string - type: - $ref: '#/components/schemas/FileType' - sub_id: - type: string + archives: + type: array + items: + $ref: '#/components/schemas/Archive' ImportArchiveRequest: type: object required: @@ -2201,7 +2195,6 @@ components: properties: archive: type: string - format: binary type: $ref: '#/components/schemas/FileType' sub_id: diff --git a/backend/app.js b/backend/app.js index 2f7ef49..1f1970b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1511,6 +1511,22 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => { }); }); +app.post('/api/getArchives', optionalJwt, async (req, res) => { + const uuid = req.isAuthenticated() ? req.user.uid : null; + const sub_id = req.body.sub_id; + const filter_obj = {user_uid: uuid, sub_id: sub_id}; + const type = req.body.type; + + // we do this for file types because if type is null, that means get files of all types + if (type) filter_obj['type'] = type; + + const archives = await db_api.getRecords('archives', filter_obj); + + res.send({ + archives: archives + }); +}); + app.post('/api/downloadArchive', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const sub_id = req.body.sub_id; @@ -1528,6 +1544,36 @@ app.post('/api/downloadArchive', optionalJwt, async (req, res) => { }); +app.post('/api/importArchive', optionalJwt, async (req, res) => { + const uuid = req.isAuthenticated() ? req.user.uid : null; + const archive = req.body.archive; + const sub_id = req.body.sub_id; + const type = req.body.type; + + const archive_text = Buffer.from(archive.split(',')[1], 'base64').toString(); + + const imported_count = await archive_api.importArchiveFile(archive_text, type, uuid, sub_id); + + res.send({ + success: !!imported_count, + imported_count: imported_count + }); +}); + +app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => { + const uuid = req.isAuthenticated() ? req.user.uid : null; + const archives = req.body.archives; + + let success = true; + for (const archive of archives) { + success &= await archive_api.removeFromArchive(archive['extractor'], archive['id'], archive['type'], uuid, archive['sub_id']); + } + + res.send({ + success: success + }); +}); + var upload_multer = multer({ dest: __dirname + '/appdata/' }); app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => { const new_path = path.join(__dirname, 'appdata', 'cookies.txt'); diff --git a/backend/archive.js b/backend/archive.js index 6df8e0e..d2a9d02 100644 --- a/backend/archive.js +++ b/backend/archive.js @@ -45,7 +45,7 @@ exports.importArchiveFile = async (archive_text, type, user_uid = null, sub_id = // we can't do a bulk write because we need to avoid duplicate archive items existing in db const archive_item = createArchiveItem(extractor, id, type, null, user_uid, sub_id); - await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id}); + await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type, sub_id: sub_id, user_uid: user_uid}); archive_import_count++; } return archive_import_count; diff --git a/src/api-types/index.ts b/src/api-types/index.ts index 9f153bb..7826604 100644 --- a/src/api-types/index.ts +++ b/src/api-types/index.ts @@ -27,7 +27,7 @@ export type { DatabaseFile } from './models/DatabaseFile'; export { DBBackup } from './models/DBBackup'; export type { DBInfoResponse } from './models/DBInfoResponse'; export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse'; -export type { DeleteArchiveItemRequest } from './models/DeleteArchiveItemRequest'; +export type { DeleteArchiveItemsRequest } from './models/DeleteArchiveItemsRequest'; export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest'; export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request'; export type { DeleteNotificationRequest } from './models/DeleteNotificationRequest'; diff --git a/src/api-types/models/DeleteArchiveItemRequest.ts b/src/api-types/models/DeleteArchiveItemRequest.ts deleted file mode 100644 index 192eb50..0000000 --- a/src/api-types/models/DeleteArchiveItemRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ - -import type { FileType } from './FileType'; - -export type DeleteArchiveItemRequest = { - extractor: string; - id: string; - type: FileType; - sub_id?: string; -}; diff --git a/src/api-types/models/DeleteArchiveItemsRequest.ts b/src/api-types/models/DeleteArchiveItemsRequest.ts new file mode 100644 index 0000000..7911f59 --- /dev/null +++ b/src/api-types/models/DeleteArchiveItemsRequest.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { Archive } from './Archive'; + +export type DeleteArchiveItemsRequest = { + archives: Array; +}; \ No newline at end of file diff --git a/src/api-types/models/ImportArchiveRequest.ts b/src/api-types/models/ImportArchiveRequest.ts index e5a5093..7c00028 100644 --- a/src/api-types/models/ImportArchiveRequest.ts +++ b/src/api-types/models/ImportArchiveRequest.ts @@ -5,7 +5,7 @@ import type { FileType } from './FileType'; export type ImportArchiveRequest = { - archive: Blob; + archive: string; type: FileType; sub_id?: string; -}; +}; \ No newline at end of file diff --git a/src/app/app.component.html b/src/app/app.component.html index c9e5894..5980509 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -21,6 +21,10 @@ person Profile + + + + Subscription + + None + {{sub.name}} + + + + File type + + Both + Video + Audio + + + + + +
+ + +
+
+ Drag and Drop +
+
+ +
+
+
+
+
+ +
+ + + + + + + +
+ {{ item.relativePath }} + +
+ + Subscription + + None + {{sub.name}} + + + + File type + + Video + Audio + + + +
+
+
diff --git a/src/app/components/archive-viewer/archive-viewer.component.scss b/src/app/components/archive-viewer/archive-viewer.component.scss index e69de29..6f7245b 100644 --- a/src/app/components/archive-viewer/archive-viewer.component.scss +++ b/src/app/components/archive-viewer/archive-viewer.component.scss @@ -0,0 +1,32 @@ +.filter { + width: 100%; +} + +.spinner { + bottom: 1px; + left: 0.5px; + position: absolute; +} + +.mat-mdc-table { + width: 100%; + max-height: 60vh; + overflow: auto; +} + +.max-two-lines { + display: -webkit-box; + display: -moz-box; + max-height: 2.4em; + line-height: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +::ng-deep .ngx-file-drop__content { + width: 100%; + top: -12px; + position: relative; +} \ No newline at end of file diff --git a/src/app/components/archive-viewer/archive-viewer.component.ts b/src/app/components/archive-viewer/archive-viewer.component.ts index 2684af8..bb09e6f 100644 --- a/src/app/components/archive-viewer/archive-viewer.component.ts +++ b/src/app/components/archive-viewer/archive-viewer.component.ts @@ -1,8 +1,13 @@ +import { SelectionModel } from '@angular/cdk/collections'; import { Component, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatSort } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; +import { FileType } from 'api-types'; import { Archive } from 'api-types/models/Archive'; +import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component'; import { PostsService } from 'app/posts.services'; +import { NgxFileDropEntry } from 'ngx-file-drop'; @Component({ selector: 'app-archive-viewer', @@ -10,23 +15,89 @@ import { PostsService } from 'app/posts.services'; styleUrls: ['./archive-viewer.component.scss'] }) export class ArchiveViewerComponent { - archives = null; - displayedColumns: string[] = ['timestamp', 'title', 'id', 'extractor']; + // table + displayedColumns: string[] = ['select', 'timestamp', 'title', 'id', 'extractor']; dataSource = null; + selection = new SelectionModel(true, []); + + // general + archives = null; archives_retrieved = false; + sub_id = 'none'; + upload_sub_id = 'none'; + type: FileType | 'both' = 'both'; + upload_type: FileType = FileType.VIDEO; + + // importing + uploading_archive = false; + uploaded_archive = false; + files = []; + + typeSelectOptions = { + video: { + key: 'video', + label: $localize`Video` + }, + audio: { + key: 'audio', + label: $localize`Audio` + } + }; @ViewChild(MatSort) sort: MatSort; - constructor(private postsService: PostsService) { + constructor(public postsService: PostsService, private dialog: MatDialog) { } - filterSelectionChanged(value: string): void { - this.getArchives(value); + ngOnInit() { + this.getArchives(); } - getArchives(sub_id: string = null): void { - this.postsService.getArchives(sub_id).subscribe(res => { + applyFilter(event: Event) { + const filterValue = (event.target as HTMLInputElement).value; + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.data.length; + return numSelected === numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + toggleAllRows() { + if (this.isAllSelected()) { + this.selection.clear(); + return; + } + + this.selection.select(...this.dataSource.data); + } + + typeFilterSelectionChanged(value): void { + this.type = value; + this.getArchives(); + } + + subFilterSelectionChanged(value): void { + this.sub_id = value; + if (this.sub_id !== 'none') { + this.type = this.postsService.getSubscriptionByID(this.sub_id)['type']; + } + this.getArchives(); + } + + subUploadFilterSelectionChanged(value): void { + this.upload_sub_id = value; + if (this.upload_sub_id !== 'none') { + this.upload_type = this.postsService.getSubscriptionByID(this.upload_sub_id)['type']; + } + } + + getArchives(): void { + this.postsService.getArchives(this.type === 'both' ? null : this.type, this.sub_id === 'none' ? null : this.sub_id).subscribe(res => { if (res['archives'] !== null && res['archives'] !== undefined && JSON.stringify(this.archives) !== JSON.stringify(res['archives'])) { @@ -38,4 +109,78 @@ export class ArchiveViewerComponent { } }); } + + importArchive(): void { + this.uploading_archive = true; + for (const droppedFile of this.files) { + // Is it a file? + if (droppedFile.fileEntry.isFile) { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file(async (file: File) => { + const archive_base64 = await blobToBase64(file); + this.postsService.importArchive(archive_base64 as string, this.upload_type, this.upload_sub_id === 'none' ? null : this.upload_sub_id).subscribe(res => { + this.uploading_archive = false; + if (res['success']) { + this.uploaded_archive = true; + this.postsService.openSnackBar($localize`Archive successfully imported!`); + } + this.getArchives(); + }, err => { + console.error(err); + this.uploading_archive = false; + }); + }); + } + } + } + + openDeleteSelectedArchivesDialog(): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: { + dialogTitle: $localize`Delete archives`, + dialogText: $localize`Would you like to delete ${this.selection.selected.length}:selected archives amount: archive(s)?`, + submitText: $localize`Delete`, + warnSubmitColor: true + } + }); + dialogRef.afterClosed().subscribe(confirmed => { + if (confirmed) { + this.deleteSelectedArchives(); + } + }); + + } + + deleteSelectedArchives(): void { + for (const archive of this.selection.selected) { + this.archives = this.archives.filter((_archive: Archive) => !(archive['extractor'] === _archive['extractor'] && archive['id'] !== _archive['id'])); + } + this.postsService.deleteArchiveItems(this.selection.selected).subscribe(res => { + if (res['success']) { + this.postsService.openSnackBar($localize`Successfully deleted archive items!`); + } else { + this.postsService.openSnackBar($localize`Failed to delete archive items!`); + } + this.getArchives(); + }); + this.selection.clear(); + } + + public dropped(files: NgxFileDropEntry[]) { + this.files = files; + this.uploading_archive = false; + this.uploaded_archive = false; + } + + originalOrder = (): number => { + return 0; + } +} + +function blobToBase64(blob: Blob) { + return new Promise((resolve, _) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); } diff --git a/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html b/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html index 759ae4d..9d08a58 100644 --- a/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html +++ b/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html @@ -27,7 +27,7 @@ {{ item.relativePath }} - + diff --git a/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.ts b/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.ts index bc3b69e..f123f6b 100644 --- a/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.ts +++ b/src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.ts @@ -39,6 +39,7 @@ export class CookiesUploaderDialogComponent implements OnInit { this.postsService.openSnackBar($localize`Cookies successfully uploaded!`); } }, err => { + console.error(err); this.uploading = false; }); }); diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 11575cd..c42f6a0 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -107,9 +107,12 @@ import { GetNotificationsResponse, UpdateTaskOptionsRequest, User, - DeleteArchiveItemRequest, + DeleteArchiveItemsRequest, GetArchivesRequest, - GetArchivesResponse + GetArchivesResponse, + ImportArchiveRequest, + Archive, + Subscription } from '../api-types'; import { isoLangs } from './settings/locales_list'; import { Title } from '@angular/platform-browser'; @@ -159,7 +162,7 @@ export class PostsService implements CanActivate { // global vars config = null; - subscriptions = null; + subscriptions: Subscription[] = null; categories: Category[] = null; sidenav = null; locale = isoLangs['en']; @@ -265,7 +268,7 @@ export class PostsService implements CanActivate { this.theme = this.THEMES_CONFIG[theme]; } - getSubscriptionByID(sub_id) { + getSubscriptionByID(sub_id: string): Subscription { for (let i = 0; i < this.subscriptions.length; i++) { if (this.subscriptions[i]['id'] === sub_id) { return this.subscriptions[i]; @@ -446,22 +449,19 @@ export class PostsService implements CanActivate { return this.http.post(this.path + 'downloadArchive', body, {responseType: 'blob', params: this.httpOptions.params}); } - getArchives(sub_id: string) { - const body: GetArchivesRequest = {sub_id: sub_id}; + getArchives(type: FileType = null, sub_id: string = null) { + const body: GetArchivesRequest = {type: type, sub_id: sub_id}; return this.http.post(this.path + 'getArchives', body, this.httpOptions); } - importArchive(archiveFile: File, type: FileType, sub_id: string = null) { - const formData = new FormData() - formData.append('archive', archiveFile, 'archive.txt'); - formData.append('type', type); - formData.append('sub_id', sub_id); - return this.http.post(this.path + 'importArchive', formData, this.httpOptions); + importArchive(archive_base64: string, type: FileType, sub_id: string = null) { + const body: ImportArchiveRequest = {archive: archive_base64, type: type, sub_id: sub_id} + return this.http.post(this.path + 'importArchive', body, this.httpOptions); } - deleteArchiveItem(extractor: string, id: string, type: FileType, sub_id: string = null) { - const body: DeleteArchiveItemRequest = {extractor: extractor, id: id, type: type, sub_id: sub_id}; - return this.http.post(this.path + 'deleteArchiveItem', body, this.httpOptions); + deleteArchiveItems(archives: Archive[]) { + const body: DeleteArchiveItemsRequest = {archives: archives}; + return this.http.post(this.path + 'deleteArchiveItems', body, this.httpOptions); } getFileFormats(url) {