diff --git a/mediarepo-ui/src/app/app.module.ts b/mediarepo-ui/src/app/app.module.ts index ffaf063..dceb63e 100644 --- a/mediarepo-ui/src/app/app.module.ts +++ b/mediarepo-ui/src/app/app.module.ts @@ -54,6 +54,10 @@ import { ImportTabSidebarComponent } from './pages/home/import-tab/import-tab-si import { NativeFileSelectComponent } from './components/inputs/native-file-select/native-file-select.component'; import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-sidebar/filesystem-import/filesystem-import.component'; import {MatCheckboxModule} from "@angular/material/checkbox"; +import { FilterDialogComponent } from './components/file-search/filter-dialog/filter-dialog.component'; +import { TagFilterListItemComponent } from './components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component'; +import { TagInputComponent } from './components/inputs/tag-input/tag-input.component'; +import { ContextMenuComponent } from './components/context-menu/context-menu.component'; @NgModule({ declarations: [ @@ -78,6 +82,10 @@ import {MatCheckboxModule} from "@angular/material/checkbox"; ImportTabSidebarComponent, NativeFileSelectComponent, FilesystemImportComponent, + FilterDialogComponent, + TagFilterListItemComponent, + TagInputComponent, + ContextMenuComponent, ], imports: [ BrowserModule, diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.html b/mediarepo-ui/src/app/components/context-menu/context-menu.component.html new file mode 100644 index 0000000..d182a4b --- /dev/null +++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss b/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss new file mode 100644 index 0000000..960a1df --- /dev/null +++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss @@ -0,0 +1,4 @@ +.menu-anchor { + visibility: hidden; + position: fixed; +} diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts b/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts new file mode 100644 index 0000000..55a3414 --- /dev/null +++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContextMenuComponent } from './context-menu.component'; + +describe('ContextMenuComponent', () => { + let component: ContextMenuComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ContextMenuComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ContextMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts b/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts new file mode 100644 index 0000000..38e0105 --- /dev/null +++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts @@ -0,0 +1,32 @@ +import { + Component, + ComponentFactoryResolver, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import {MatMenuTrigger} from "@angular/material/menu"; + +@Component({ + selector: 'app-context-menu', + templateUrl: './context-menu.component.html', + styleUrls: ['./context-menu.component.scss'] +}) +export class ContextMenuComponent { + + + public x: string = "0"; + public y: string = "0"; + + @ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger + + constructor() { + } + + public onContextMenu(event: MouseEvent) { + event.preventDefault(); + this.x = event.clientX + "px"; + this.y = event.clientY + "px"; + this.menuTrigger.openMenu(); + } +} diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.html b/mediarepo-ui/src/app/components/file-search/file-search.component.html index 63ce7e1..cfd3ae7 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.html +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.html @@ -16,6 +16,9 @@ [formControl]="formControl" [matAutocomplete]="auto" matInput/> + {{tag}} diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.scss b/mediarepo-ui/src/app/components/file-search/file-search.component.scss index 15a087f..1dfe215 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.scss +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.scss @@ -54,3 +54,9 @@ cursor: pointer; white-space: nowrap; } + +.filter-dialog-button { + position: absolute; + right: -13px; + top: -20px; +} diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.ts b/mediarepo-ui/src/app/components/file-search/file-search.component.ts index 2e9c991..da3f060 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.ts +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.ts @@ -23,6 +23,8 @@ import { FilterExpression, SingleFilterExpression } from "../../models/FilterExpression"; +import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component"; +import {Tag} from "../../models/Tag"; @Component({ @@ -37,6 +39,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { public filters: FilterExpression[] = []; public suggestionTags: Observable; + @Input() availableTags: Tag[] = []; @Input() validTags: string[] = []; @Output() searchStartEvent = new EventEmitter(); @Output() searchEndEvent = new EventEmitter(); @@ -70,12 +73,9 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { } public addSearchTag(tag: string) { - if (tag.startsWith("-")) { - tag = tag.replace(/^-/g, ''); - this.filters.push(new SingleFilterExpression(new TagQuery(tag, true))); - } else { - this.filters.push(new SingleFilterExpression(new TagQuery(tag, false))); - } + this.filters.push(new SingleFilterExpression(TagQuery.fromString(tag))); + tag = tag.replace(/^-/g, ''); + if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { const index = this.filters.findIndex(t => t.partiallyEq(tag)); this.filters.splice(index, 1); @@ -143,4 +143,23 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { .map(t => negated ? "-" + t : t) .slice(0, 20); } + + public openFilterDialog(): void { + const filterEntries = this.filters.map(f => f.clone()); + const filterDialog = this.dialog.open(FilterDialogComponent, { + minWidth: "25vw", + height: "80vh", + data: { + filterEntries, + availableTags: this.availableTags, + }, + disableClose: true, + }); + filterDialog.afterClosed().subscribe(async (filterExpression) => { + if (filterExpression !== undefined || filterExpression?.length > 0) { + this.filters = filterExpression; + await this.searchForFiles(); + } + }); + } } diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html new file mode 100644 index 0000000..3d76050 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html @@ -0,0 +1,23 @@ +

Filters

+
+
+ + + + + +
+ + +
+
+ + +
+ + + + + diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss new file mode 100644 index 0000000..ca664f3 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss @@ -0,0 +1,41 @@ +.dialog-actions { + display: flex; + flex-direction: row-reverse; + width: 100%; + + button { + margin-left: 1em; + } +} + +.tag-input { + width: 100%; +} + +mat-list-item.filter-list-item { + height: 100%; + padding: 0.5em 0; + user-select: none; + cursor: pointer; +} + +app-tag-filter-list-item { + width: 100%; +} + +.selected { + background-color: #5c5c5c; +} + +.filter-dialog-content { + overflow: hidden; + height: 100%; + width: 100%; + margin: 0; +} + +.filter-dialog-list { + overflow-y: auto; + height: 100%; + width: 100%; +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts new file mode 100644 index 0000000..7c3bedb --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilterDialogComponent } from './filter-dialog.component'; + +describe('FilterDialogComponent', () => { + let component: FilterDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FilterDialogComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FilterDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts new file mode 100644 index 0000000..ce97c95 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts @@ -0,0 +1,167 @@ +import {Component, HostListener, Inject, ViewChildren} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; +import {SortDialogComponent} from "../sort-dialog/sort-dialog.component"; +import { + FilterExpression, + OrFilterExpression, + SingleFilterExpression +} from "../../../models/FilterExpression"; +import {TagQuery} from "../../../models/TagQuery"; +import {Tag} from "../../../models/Tag"; +import {TagFilterListItemComponent} from "./tag-filter-list-item/tag-filter-list-item.component"; +import {Selectable} from "../../../models/Selectable"; + +@Component({ + selector: 'app-filter-dialog', + templateUrl: './filter-dialog.component.html', + styleUrls: ['./filter-dialog.component.scss'] +}) +export class FilterDialogComponent { + + public filters: Selectable[]; + public availableTags: Tag[] = []; + public mode: "AND" | "OR" = "AND"; + + @ViewChildren( + TagFilterListItemComponent) filterListItems!: TagFilterListItemComponent[]; + + private selectedQueries: TagQuery[] = []; + + constructor(public dialogRef: MatDialogRef, @Inject( + MAT_DIALOG_DATA) data: any) { + this.filters = data.filterEntries.map( + (f: FilterExpression) => new Selectable(f, + false)) ?? []; + this.availableTags = data.availableTags ?? []; + } + + public cancelFilter(): void { + this.dialogRef.close(); + } + + public confirmFilter(): void { + this.dialogRef.close(this.filters.map(f => f.data)); + } + + public removeFilter(event: TagFilterListItemComponent): void { + const filter = event.expression; + const index = this.filters.findIndex(f => f === filter); + if (index >= 0) { + this.filters.splice(index, 1); + } + this.unselectAll(); + } + + public addFilter(tag: string) { + const query = TagQuery.fromString(tag); + + if (this.mode === "AND" || this.filters.length === 0) { + this.filters.push( + new Selectable(new SingleFilterExpression(query), + false)); + tag = tag.replace(/^-/g, ''); + + if (this.filters.filter(t => t.data.partiallyEq(tag)).length > 1) { + const index = this.filters.findIndex(t => t.data.partiallyEq(tag)); + this.filters.splice(index, 1); + } + } else { + let queryList = this.filters.pop()?.data.queryList() ?? []; + + queryList.push(query); + const filterExpression = new OrFilterExpression(queryList); + filterExpression.removeDuplicates(); + this.filters.push( + new Selectable(filterExpression, + false)); + } + this.unselectAll(); + } + + public addToSelection(query: TagQuery): void { + this.selectedQueries.push(query); + } + + public removeFromSelection(query: TagQuery): void { + const index = this.selectedQueries.indexOf(query); + if (index > 0) { + this.selectedQueries.splice(index, 1); + } + } + + public unselectAll() { + this.filters.forEach(filter => filter.selected = false); + this.selectedQueries = []; + this.filterListItems.forEach(i => i.selectedIndices = []); + } + + public convertSelectionToAndExpression(): void { + for (const query of this.selectedQueries) { + this.filters.push(new Selectable(new SingleFilterExpression(query), false)); + } + this.removeFilterDuplicates(); + this.unselectAll(); + } + + public convertSelectionToOrExpression(): void { + const queries = this.selectedQueries; + const expression = new OrFilterExpression(queries); + this.filters.push(new Selectable(expression, false)); + this.removeFilterDuplicates(); + this.unselectAll(); + } + + public invertSelection(): void { + this.selectedQueries.forEach(query => query.negate = !query.negate); + } + + private removeFilterDuplicates() { + const filters = this.filters; + let newFilters: Selectable[] = []; + + for (const filterItem of filters) { + if (filterItem.data.filter_type == "OrExpression") { + (filterItem.data as OrFilterExpression).removeDuplicates(); + } + if (newFilters.findIndex(f => FilterDialogComponent.checkFiltersEqual(f.data, filterItem.data)) < 0) { + if (filterItem.data.filter_type == "OrExpression" && filterItem.data.queryList().length === 1) { + filterItem.data = new SingleFilterExpression(filterItem.data.queryList()[0]); + } + newFilters.push(filterItem); + } + } + this.filters = newFilters; + } + + private static checkFiltersEqual(l: FilterExpression, r: FilterExpression): boolean { + const lTags = l.queryList().map(q => q.getNormalizedTag()).sort(); + const rTags = r.queryList().map(q => q.getNormalizedTag()).sort(); + let match = false; + + if (lTags.length == rTags.length) { + match = true; + + for (const tag of lTags) { + match = rTags.includes(tag); + if (!match) { + break; + } + } + } + return match; + } + + @HostListener("window:keydown", ["$event"]) + private async handleKeydownEvent(event: KeyboardEvent) { + if (event.key === "Shift") { + this.mode = "OR"; + } + } + + @HostListener("window:keyup", ["$event"]) + private async handleKeyupEvent(event: KeyboardEvent) { + if (event.key === "Shift") { + this.mode = "AND"; + } + } +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html new file mode 100644 index 0000000..b1116e3 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html @@ -0,0 +1,18 @@ +
+ {{expression.data.getDisplayName()}} + +
+
+ + + OR + {{entry[1].getNormalizedTag()}} + + + +
diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss new file mode 100644 index 0000000..37a6050 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss @@ -0,0 +1,41 @@ +.remove-button, .remove-button-inner-list { + position: absolute; + right: 0; + z-index: 999; + top: calc(0.5em - 15px); +} + +.remove-button { + right: 16px; +} + +mat-list { + height: 100%; + width: 100%; + display: block; + background-color: #353535; + padding: 0; + border-radius: 0.25em; +} + +mat-list-item.or-filter-list-item { + padding: 0.5em 0; + height: 100%; + width: 100%; + border-collapse: collapse; + cursor: pointer; + user-select: none; + + ::ng-deep .mat-list-item-content { + padding-right: 0; + margin-right: 0; + } +} + +.or-span { + margin-right: 0.5em; +} + +.selected { + background-color: #5c5c5c; +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts new file mode 100644 index 0000000..c6a054a --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TagFilterListItemComponent } from './tag-filter-list-item.component'; + +describe('TagFilterListItemComponent', () => { + let component: TagFilterListItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TagFilterListItemComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TagFilterListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts new file mode 100644 index 0000000..f2d0b03 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts @@ -0,0 +1,75 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + Inject, + Input, OnChanges, + OnInit, + Output, SimpleChanges +} from '@angular/core'; +import { + FilterExpression, + OrFilterExpression, SingleFilterExpression +} from "../../../../models/FilterExpression"; +import {TagQuery} from "../../../../models/TagQuery"; +import {Selectable} from "../../../../models/Selectable"; + +@Component({ + selector: 'app-tag-filter-list-item', + templateUrl: './tag-filter-list-item.component.html', + styleUrls: ['./tag-filter-list-item.component.scss'] +}) +export class TagFilterListItemComponent implements OnChanges { + + @Input() expression!: Selectable; + @Output() removeClicked = new EventEmitter(); + @Output() querySelect = new EventEmitter(); + @Output() queryUnselect = new EventEmitter(); + + public selectedIndices: number[] = []; + + constructor(private changeDetector: ChangeDetectorRef) { } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes["expression"]) { + this.selectedIndices = []; + } + } + + public enumerate(items: T[]): [number, T][] { + return items.map((value, index) => [index, value]); + } + + public removeOrExpression(index: number) { + const expression = this.expression.data as OrFilterExpression; + expression.removeQueryEntry(index); + + if (expression.filter.length == 0) { + this.removeClicked.emit(this); + } else if (expression.filter.length == 1) { + this.expression.data = new SingleFilterExpression(expression.filter[0]); + } + } + + public selectInnerIndex(index: number): void { + const expression = this.expression.data as OrFilterExpression; + + if (this.selectedIndices.includes(index)) { + const elementIndex = this.selectedIndices.indexOf(index); + this.selectedIndices.splice(elementIndex, 1); + this.queryUnselect.emit(expression.filter[index]); + } else { + this.selectedIndices.push(index); + this.querySelect.emit(expression.filter[index]); + } + } + + public onSelect(): void { + this.expression.selected = !this.expression.selected; + if (this.expression.selected) { + this.querySelect.emit(this.expression.data.filter as TagQuery); + } else { + this.queryUnselect.emit(this.expression.data.filter as TagQuery); + } + } +} diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html new file mode 100644 index 0000000..b1b8997 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html @@ -0,0 +1,15 @@ + + + Enter a tag + + + + + {{tag}} + + + diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss new file mode 100644 index 0000000..c7acb4b --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts new file mode 100644 index 0000000..b94bb54 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TagInputComponent } from './tag-input.component'; + +describe('TagInputComponent', () => { + let component: TagInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TagInputComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TagInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts new file mode 100644 index 0000000..178f138 --- /dev/null +++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts @@ -0,0 +1,114 @@ +import { + Component, ElementRef, + EventEmitter, + Input, OnChanges, + OnInit, + Output, SimpleChanges, + ViewChild +} from '@angular/core'; +import {Tag} from "../../../models/Tag"; +import {FormControl} from "@angular/forms"; +import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete"; +import {Observable} from "rxjs"; +import {debounceTime, delay, map, startWith} from "rxjs/operators"; + +@Component({ + selector: 'app-tag-input', + templateUrl: './tag-input.component.html', + styleUrls: ['./tag-input.component.scss'] +}) +export class TagInputComponent implements OnChanges{ + + @Input() availableTags: Tag[] = []; + @Input() allowNegation: boolean = false; + @Input() allowInvalid: boolean = false; + @Output() tagAdded = new EventEmitter(); + + @ViewChild("tagInput") tagInput!: ElementRef; + public formControl = new FormControl(); + public autosuggestTags: Observable; + private tagsForAutocomplete: string[] = []; + + constructor() { + this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput()); + this.autosuggestTags = this.formControl.valueChanges.pipe( + startWith(null), + debounceTime(250), + map((tag: string | null) => tag ? this.filterSuggestionTag(tag) : this.tagsForAutocomplete.slice(0, 20))); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["availableTags"]) { + this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput()); + } + } + + public addTagByInput(event: any): void { + this.addTag(this.formControl.value); + } + + public addTagByAutocomplete(event: MatAutocompleteSelectedEvent): void { + this.addTag(event.option.value); + } + + private addTag(value: string) { + const tag = this.normalizeTag(value); + if (tag.length > 0 && (this.allowInvalid || this.checkTagValid(tag))) { + this.tagAdded.emit(tag); + this.formControl.setValue(""); + this.tagInput.nativeElement.value = ""; + } + } + + private filterSuggestionTag(tag: string) { + let normalizedTag = this.normalizeTag(tag); + const negated = normalizedTag.startsWith("-") && this.allowNegation; + normalizedTag = this.allowNegation? normalizedTag.replace(/^-/, "") : normalizedTag; + + return this.tagsForAutocomplete.filter( + t => t.includes(normalizedTag)) + .map(t => negated ? "-" + t: t) + .sort((l, r) => this.compareSuggestionTags(normalizedTag, l, r)) + .slice(0, 20); + } + + private checkTagValid(tag: string): boolean { + if (this.allowNegation) { + tag = tag.replace(/^-/, ""); + } + return this.tagsForAutocomplete.includes(tag); + } + + /** + * Normalizes the tag by removing whitespaces + * @param {string} tag + * @returns {string} + * @private + */ + private normalizeTag(tag: string): string { + let normalizedTag = tag.trim(); + let parts = normalizedTag.split(":"); + + if (parts.length > 1) { + const namespace = parts.shift()!.trim(); + const name = parts.join(":").trim(); + return namespace + ":" + name; + } else { + return normalizedTag; + } + } + + private compareSuggestionTags(query: string, l: string, r: string): number { + if (l.startsWith(query) && !r.startsWith(query)) { + return -1; + } else if (!l.startsWith(query) && r.startsWith(query)) { + return 1; + } else if (l.length < r.length) { + return -1; + } else if (l.length > r.length) { + return 1; + } else { + return l.localeCompare(r) + } + } +} diff --git a/mediarepo-ui/src/app/models/FilterExpression.ts b/mediarepo-ui/src/app/models/FilterExpression.ts index 33ac090..e2e8d62 100644 --- a/mediarepo-ui/src/app/models/FilterExpression.ts +++ b/mediarepo-ui/src/app/models/FilterExpression.ts @@ -9,6 +9,10 @@ export interface FilterExpression { partiallyEq(value: any): boolean; getDisplayName(): string; + + clone(): FilterExpression; + + queryList(): TagQuery[]; } export class OrFilterExpression implements FilterExpression{ @@ -30,6 +34,31 @@ export class OrFilterExpression implements FilterExpression{ public getDisplayName(): string { return this.filter.map(t => t.getNormalizedTag()).join(" OR "); } + + public clone(): OrFilterExpression { + let tags = this.filter.map((t: TagQuery) => new TagQuery(t.tag, t.negate)); + return new OrFilterExpression(tags) + } + + public queryList(): TagQuery[] { + return this.filter; + } + + public removeQueryEntry(index: number) { + this.filter.splice(index, 1); + } + + public removeDuplicates() { + const filters = this.filter.reverse(); + let newEntries: TagQuery[] = []; + + for (const entry of filters) { + if (newEntries.findIndex(f => f.tag === entry.tag) < 0) { + newEntries.push(entry); + } + } + this.filter = newEntries.reverse(); + } } export class SingleFilterExpression implements FilterExpression { @@ -51,4 +80,12 @@ export class SingleFilterExpression implements FilterExpression { public getDisplayName(): string { return this.filter.getNormalizedTag(); } + + public clone(): FilterExpression { + return new SingleFilterExpression(new TagQuery(this.filter.tag, this.filter.negate)) + } + + public queryList(): TagQuery[] { + return [this.filter] + } } diff --git a/mediarepo-ui/src/app/models/TagQuery.ts b/mediarepo-ui/src/app/models/TagQuery.ts index f10e907..493f5c7 100644 --- a/mediarepo-ui/src/app/models/TagQuery.ts +++ b/mediarepo-ui/src/app/models/TagQuery.ts @@ -1,7 +1,17 @@ +import {SingleFilterExpression} from "./FilterExpression"; + export class TagQuery { constructor(public tag: string, public negate: boolean) { } + public static fromString(tag: string): TagQuery { + if (tag.startsWith("-")) { + return new TagQuery(tag.replace(/^-/g, ''), true); + } else { + return new TagQuery(tag, false); + } + } + public getNormalizedTag(): string { return this.negate ? "-" + this.tag : this.tag; } diff --git a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html index e837b8f..83d9da1 100644 --- a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html +++ b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html @@ -5,7 +5,8 @@
+ [validTags]="this.getValidTagsForSearch()" + [availableTags]="this.allTags">
diff --git a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.ts b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.ts index 062c8c2..6767c0c 100644 --- a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.ts +++ b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.ts @@ -32,6 +32,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges { public tagsOfFiles: Tag[] = []; public tags: Tag[] = []; + public allTags: Tag[] = []; public files: File[] = []; public tagsOfSelection: Tag[] = []; @@ -43,6 +44,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges { }); this.repoService.selectedRepository.subscribe( async (repo) => repo && this.fileSearch && await this.fileSearch.searchForFiles()); + this.tagService.tags.subscribe(t => this.allTags = t); } async ngOnInit() {