From 501a3c9df0d83f9710ef330ac49292c751a994c6 Mon Sep 17 00:00:00 2001 From: trivernis Date: Wed, 12 Jan 2022 21:45:57 +0100 Subject: [PATCH] Add extended filtering implementation Signed-off-by: trivernis --- mediarepo-ui/.editorconfig | 23 +- mediarepo-ui/.gitignore | 3 + mediarepo-ui/package.json | 117 +- mediarepo-ui/src-tauri/Cargo.lock | 4 +- mediarepo-ui/src-tauri/Cargo.toml | 2 +- mediarepo-ui/src/api/Api.ts | 20 +- mediarepo-ui/src/api/ShortCache.ts | 69 + mediarepo-ui/src/api/api-types/files.ts | 50 +- .../src/api/models/FilterQueryBuilder.ts | 292 + mediarepo-ui/src/api/models/SearchFilters.ts | 75 + mediarepo-ui/src/app/app.component-theme.scss | 2 +- .../repositories-tab.component.ts | 58 +- .../shared/app-common/app-common.module.ts | 22 +- .../busy-dialog/busy-dialog.component.spec.ts | 38 +- .../context-menu/context-menu.component.ts | 2 +- .../file-grid/file-grid.component.ts | 110 +- .../file-thumbnail.component.ts | 15 +- .../filesystem-import.component.ts | 22 +- .../file-search/file-search.component.html | 15 +- .../file-search/file-search.component.ts | 68 +- .../filter-expression-item.component.html | 14 + .../filter-expression-item.component.scss | 0 .../filter-expression-item.component.spec.ts | 25 + .../filter-expression-item.component.ts | 47 + .../property-query-item.component.html | 1 + .../property-query-item.component.scss | 0 .../property-query-item.component.spec.ts | 25 + .../property-query-item.component.ts | 101 + .../tag-query-item.component.html | 1 + .../tag-query-item.component.scss | 0 .../tag-query-item.component.spec.ts | 25 + .../tag-query-item.component.ts | 15 + .../sort-dialog/sort-dialog.component.ts | 33 +- .../shared/sidebar/sidebar.module.ts | 20 +- mediarepo-ui/src/app/models/AppState.ts | 31 +- mediarepo-ui/src/app/models/GenericFilter.ts | 4 +- mediarepo-ui/src/app/models/TabState.ts | 82 +- mediarepo-ui/src/app/models/rust-types.ts | 4 +- .../error-broker/error-broker.service.ts | 6 +- .../src/app/services/file/file.service.ts | 25 +- .../src/app/services/job/job.service.spec.ts | 22 +- .../services/scheduling/scheduling.service.ts | 16 +- mediarepo-ui/src/styles.scss | 20 +- mediarepo-ui/yarn.lock | 5122 ++++++----------- 44 files changed, 3021 insertions(+), 3625 deletions(-) create mode 100644 mediarepo-ui/src/api/ShortCache.ts create mode 100644 mediarepo-ui/src/api/models/FilterQueryBuilder.ts create mode 100644 mediarepo-ui/src/api/models/SearchFilters.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/filter-expression-item.component.html create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/filter-expression-item.component.scss create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/filter-expression-item.component.spec.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/filter-expression-item.component.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/property-query-item/property-query-item.component.html create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/property-query-item/property-query-item.component.scss create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/property-query-item/property-query-item.component.spec.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/property-query-item/property-query-item.component.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/tag-query-item/tag-query-item.component.html create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/tag-query-item/tag-query-item.component.scss create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/tag-query-item/tag-query-item.component.spec.ts create mode 100644 mediarepo-ui/src/app/components/shared/sidebar/file-search/filter-expression-item/tag-query-item/tag-query-item.component.ts diff --git a/mediarepo-ui/.editorconfig b/mediarepo-ui/.editorconfig index 783e38e..87ae181 100644 --- a/mediarepo-ui/.editorconfig +++ b/mediarepo-ui/.editorconfig @@ -9,8 +9,27 @@ insert_final_newline = true trim_trailing_whitespace = true [*.ts] -quote_type = double +trim_trailing_whitespace = true +charset = utf-8 +ij_typescript_align_multiline_chained_methods = true +ij_typescript_force_quote_style = true +ij_typescript_space_after_colon = true +ij_typescript_space_after_comma = true +ij_typescript_space_after_property_colon = true +ij_typescript_space_after_type_colon = true +ij_any_spaces_within_braces = true +ij_typescript_spaces_within_object_type_braces = true +ij_typescript_spaces_within_object_literal_braces = true +ij_any_call_parameters_wrap = on_every_item +ij_any_method_parameters_wrap = on_every_item +ij_any_method_call_chain_wrap = on_every_item +ij_typescript_method_parameters_wrap = on_every_item +ij_any_call_parameters_new_line_after_left_paren = true +ij_any_call_parameters_right_paren_on_new_line = true +ij_any_method_parameters_new_line_after_left_paren = true +ij_any_method_parameters_right_paren_on_new_line = true +max_line_length = 120 [*.md] max_line_length = off -trim_trailing_whitespace = false +trim_trailing_whitespace = false \ No newline at end of file diff --git a/mediarepo-ui/.gitignore b/mediarepo-ui/.gitignore index de51f68..28cb778 100644 --- a/mediarepo-ui/.gitignore +++ b/mediarepo-ui/.gitignore @@ -7,6 +7,9 @@ # Only exists if Bazel was run /bazel-out +# angular stuff +.angular + # dependencies /node_modules diff --git a/mediarepo-ui/package.json b/mediarepo-ui/package.json index e8aed3d..8491755 100644 --- a/mediarepo-ui/package.json +++ b/mediarepo-ui/package.json @@ -1,61 +1,60 @@ { - "name": "mediarepo-ui", - "version": "0.12.0", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test", - "lint": "ng lint", - "tauri": "tauri" - }, - "private": true, - "dependencies": { - "@angular/animations": "~12.2.0", - "@angular/cdk": "12.2.9", - "@angular/common": "~12.2.0", - "@angular/compiler": "~12.2.0", - "@angular/core": "~12.2.0", - "@angular/flex-layout": "^12.0.0-beta.35", - "@angular/forms": "~12.2.0", - "@angular/material": "12.2.9", - "@angular/platform-browser": "~12.2.0", - "@angular/platform-browser-dynamic": "~12.2.0", - "@angular/router": "~12.2.0", - "@ng-icons/core": "^13.1.1", - "@ng-icons/feather-icons": "^13.1.1", - "@ng-icons/material-icons": "^13.1.1", - "@tauri-apps/api": "^1.0.0-beta.8", - "ngx-lightbox": "^2.5.1", - "primeicons": "^4.1.0", - "primeng": "^12.2.1", - "rxjs": "~6.6.0", - "tslib": "^2.3.0", - "zone.js": "~0.11.4" - }, - "devDependencies": { - "@angular-devkit/build-angular": "~12.2.9", - "@angular-eslint/builder": "12.5.0", - "@angular-eslint/eslint-plugin": "12.5.0", - "@angular-eslint/eslint-plugin-template": "12.5.0", - "@angular-eslint/schematics": "12.5.0", - "@angular-eslint/template-parser": "12.5.0", - "@angular/cli": "~12.2.9", - "@angular/compiler-cli": "~12.2.0", - "@tauri-apps/cli": "^1.0.0-beta.10", - "@types/file-saver": "^2.0.3", - "@types/jasmine": "~3.8.0", - "@types/node": "^12.11.1", - "@typescript-eslint/eslint-plugin": "4.28.2", - "@typescript-eslint/parser": "4.28.2", - "eslint": "^7.26.0", - "jasmine-core": "~3.8.0", - "karma": "~6.3.0", - "karma-chrome-launcher": "~3.1.0", - "karma-coverage": "~2.0.3", - "karma-jasmine": "~4.0.0", - "karma-jasmine-html-reporter": "~1.7.0", - "typescript": "~4.3.5" - } + "name": "mediarepo-ui", + "version": "0.12.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "lint": "ng lint", + "tauri": "tauri" + }, + "private": true, + "dependencies": { + "@angular/animations": "~13.1.1", + "@angular/cdk": "^13.1.1", + "@angular/common": "~13.1.1", + "@angular/compiler": "~13.1.1", + "@angular/core": "~13.1.1", + "@angular/flex-layout": "^13.0.0-beta.36", + "@angular/forms": "~13.1.1", + "@angular/material": "^13.1.1", + "@angular/platform-browser": "~13.1.1", + "@angular/platform-browser-dynamic": "~13.1.1", + "@angular/router": "~13.1.1", + "@ng-icons/core": "^13.2.0", + "@ng-icons/feather-icons": "^13.2.0", + "@ng-icons/material-icons": "13.1.0", + "@tauri-apps/api": "^1.0.0-beta.8", + "primeicons": "^5.0.0", + "primeng": "^13.0.4", + "rxjs": "~7.5.2", + "tslib": "^2.3.1", + "zone.js": "~0.11.4" + }, + "devDependencies": { + "@angular-devkit/build-angular": "~13.1.2", + "@angular-eslint/builder": "^13.0.1", + "@angular-eslint/eslint-plugin": "^13.0.1", + "@angular-eslint/eslint-plugin-template": "^13.0.1", + "@angular-eslint/schematics": "^13.0.1", + "@angular-eslint/template-parser": "^13.0.1", + "@angular/cli": "~13.1.2", + "@angular/compiler-cli": "~13.1.1", + "@tauri-apps/cli": "^1.0.0-beta.10", + "@types/file-saver": "^2.0.4", + "@types/jasmine": "~3.10.3", + "@types/node": "^16.11.19", + "@typescript-eslint/eslint-plugin": "5.9.1", + "@typescript-eslint/parser": "^5.9.1", + "eslint": "^8.6.0", + "jasmine-core": "~4.0.0", + "karma": "~6.3.10", + "karma-chrome-launcher": "~3.1.0", + "karma-coverage": "~2.1.0", + "karma-jasmine": "~4.0.1", + "karma-jasmine-html-reporter": "~1.7.0", + "typescript": "~4.5.4" + } } diff --git a/mediarepo-ui/src-tauri/Cargo.lock b/mediarepo-ui/src-tauri/Cargo.lock index a6f44ae..f9a3751 100644 --- a/mediarepo-ui/src-tauri/Cargo.lock +++ b/mediarepo-ui/src-tauri/Cargo.lock @@ -1489,8 +1489,8 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "mediarepo-api" -version = "0.25.0" -source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=cd7bcc8d688d05275679d20af71231098602009b#cd7bcc8d688d05275679d20af71231098602009b" +version = "0.26.0" +source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=bd21f48f41aa2943f76b21addf137b2e58d492ca#bd21f48f41aa2943f76b21addf137b2e58d492ca" dependencies = [ "async-trait", "bromine", diff --git a/mediarepo-ui/src-tauri/Cargo.toml b/mediarepo-ui/src-tauri/Cargo.toml index f9bb22b..ff17dd3 100644 --- a/mediarepo-ui/src-tauri/Cargo.toml +++ b/mediarepo-ui/src-tauri/Cargo.toml @@ -25,7 +25,7 @@ features = [ "env-filter" ] [dependencies.mediarepo-api] git = "https://github.com/Trivernis/mediarepo-api.git" -rev = "cd7bcc8d688d05275679d20af71231098602009b" +rev = "bd21f48f41aa2943f76b21addf137b2e58d492ca" features = [ "tauri-plugin" ] [features] diff --git a/mediarepo-ui/src/api/Api.ts b/mediarepo-ui/src/api/Api.ts index fde6655..817a193 100644 --- a/mediarepo-ui/src/api/Api.ts +++ b/mediarepo-ui/src/api/Api.ts @@ -25,12 +25,9 @@ import { StartDaemonRequest, UpdateFileNameRequest } from "./api-types/requests"; -import { - RepositoryData, - RepositoryMetadata, - SizeMetadata -} from "./api-types/repo"; +import {RepositoryData, RepositoryMetadata, SizeMetadata} from "./api-types/repo"; import {NamespaceData, TagData} from "./api-types/tags"; +import {ShortCache} from "./ShortCache"; export class MediarepoApi { @@ -99,7 +96,7 @@ export class MediarepoApi { } public static async findFiles(request: FindFilesRequest): Promise { - return this.invokePlugin(ApiFunction.FindFiles, request); + return ShortCache.cached(request, () => this.invokePlugin(ApiFunction.FindFiles, request), 5000, "findFiles"); } public static async getFileMetadata(request: GetFileMetadataRequest): Promise { @@ -123,7 +120,7 @@ export class MediarepoApi { } public static async getAllTags(): Promise { - return this.invokePlugin(ApiFunction.GetAllTags); + return ShortCache.cached("all-tags", () => this.invokePlugin(ApiFunction.GetAllTags), 2000); } public static async getAllNamespaces(): Promise { @@ -131,7 +128,12 @@ export class MediarepoApi { } public static async getTagsForFiles(request: GetTagsForFilesRequest): Promise { - return this.invokePlugin(ApiFunction.GetTagsForFiles, request); + return ShortCache.cached( + request, + () => this.invokePlugin(ApiFunction.GetTagsForFiles, request), + 1000, + "getTagsForFiles" + ); } public static async createTags(request: CreateTagsRequest): Promise { @@ -151,7 +153,7 @@ export class MediarepoApi { } public static async getFrontendState(): Promise { - return this.invokePlugin(ApiFunction.GetFrontendState); + return ShortCache.cached("frontend-state", () => this.invokePlugin(ApiFunction.GetFrontendState), 1000); } public static async setFrontendState(request: SetFrontendStateRequest): Promise { diff --git a/mediarepo-ui/src/api/ShortCache.ts b/mediarepo-ui/src/api/ShortCache.ts new file mode 100644 index 0000000..d8f73b3 --- /dev/null +++ b/mediarepo-ui/src/api/ShortCache.ts @@ -0,0 +1,69 @@ +type CacheEntry = { + ttl: number, + value: T, +} + +const cacheMap: { + [key: string]: CacheEntry +} = {}; + +export class ShortCache { + + public static async cached( + key: any, + producer: () => Promise, + ttl: number = 1000, + prefix: string = "" + ): Promise { + const cacheKey = prefix + JSON.stringify(key); + const entry = this.getCacheEntry(cacheKey, ttl); + + if (entry) { + console.debug("cache hit for key", cacheKey); + return entry; + } else { + console.debug("cache miss key", cacheKey); + const value = await producer(); + this.addCacheEntry(cacheKey, value, ttl); + return value; + } + } + + public static startTicking() { + (async () => { + while (true) { + ShortCache.tick(); + await new Promise(resolve => setTimeout(resolve, 100)); + } + })(); + } + + private static addCacheEntry(key: string, value: any, ttl: number) { + cacheMap[key] = { + ttl, + value + }; + console.debug("added cache entry with key", key); + } + + private static getCacheEntry(key: string, ttl: number): T | undefined { + const entry = cacheMap[key]; + if (entry) { + entry.ttl = ttl; + } + return entry?.value; + } + + private static tick() { + for (let key in cacheMap) { + cacheMap[key].ttl -= 100; + + if (cacheMap[key].ttl < 0) { + console.debug("purged cache entry with key", key); + delete cacheMap[key]; + } + } + } +} + +ShortCache.startTicking(); diff --git a/mediarepo-ui/src/api/api-types/files.ts b/mediarepo-ui/src/api/api-types/files.ts index 0c58eb7..830e08f 100644 --- a/mediarepo-ui/src/api/api-types/files.ts +++ b/mediarepo-ui/src/api/api-types/files.ts @@ -1,12 +1,56 @@ -export type FilterExpression = - { OrExpression: TagQuery[] } - | { Query: TagQuery }; +export type FilterExpression = FilterExpressionOrExpression | FilterExpressionQuery; + +export type FilterExpressionOrExpression = { + OrExpression: FilterQuery[], +}; +export type FilterExpressionQuery = { + Query: FilterQuery; +}; + +export type FilterQuery = FilterQueryTag | FilterQueryProperty; + +export type FilterQueryTag = { Tag: TagQuery }; +export type FilterQueryProperty = { Property: PropertyQuery }; export type TagQuery = { negate: boolean, tag: string, }; +export type PropertyKeys = + "Status" + | "FileSize" + | "ImportedTime" + | "ChangedTime" + | "CreatedTime" + | "TagCount" + | "Cd" + | "Id"; + +export type PropertyQuery = PropertyQueryStatus + | PropertyQueryFileSize + | PropertyQueryImportedTime + | PropertyQueryChangedTime + | PropertyQueryCreatedTime + | PropertyQueryTagCount + | PropertyQueryCd + | PropertyQueryId; + +export type PropertyQueryStatus = { Status: FileStatus }; +export type PropertyQueryFileSize = { FileSize: ValueComparator }; +export type PropertyQueryImportedTime = { ImportedTime: ValueComparator }; +export type PropertyQueryChangedTime = { ChangedTime: ValueComparator }; +export type PropertyQueryCreatedTime = { CreatedTime: ValueComparator }; +export type PropertyQueryTagCount = { TagCount: ValueComparator }; +export type PropertyQueryCd = { Cd: string }; +export type PropertyQueryId = { Id: number }; + +export type ValueComparator = + { Less: T } + | { Equal: T } + | { Greater: T } + | { Between: T[] } + export type SortKey = { Namespace: SortNamespace } | { FileName: SortDirection } | { FileSize: SortDirection } diff --git a/mediarepo-ui/src/api/models/FilterQueryBuilder.ts b/mediarepo-ui/src/api/models/FilterQueryBuilder.ts new file mode 100644 index 0000000..770190c --- /dev/null +++ b/mediarepo-ui/src/api/models/FilterQueryBuilder.ts @@ -0,0 +1,292 @@ +import {FileStatus, FilterQuery, PropertyQuery, ValueComparator} from "../api-types/files"; + +export type Comparator = "Less" | "Equal" | "Greater" | "Between"; +export type PropertyType = + "Status" + | "FileSize" + | "ImportedTime" + | "ChangedTime" + | "CreatedTime" + | "TagCount" + | "Cd" + | "Id"; + +export class FilterQueryBuilder { + + public static tag(tag: string, negate: boolean): FilterQuery { + return { Tag: { tag, negate } }; + } + + public static status(status: FileStatus): FilterQuery { + return filterQuery({ Status: status }); + } + + public static fileSize(size: number, comparator: Comparator, max_size?: number): FilterQuery { + return filterQuery( + { FileSize: valuesToCompareEnum(size, comparator, max_size) }); + } + + public static importedTime(date: Date, comparator: Comparator, max_date: Date): FilterQuery { + return filterQuery({ + ImportedTime: valuesToCompareEnum(date, comparator, + max_date + ) + }); + } + + public static changedTime(date: Date, comparator: Comparator, max_date: Date): FilterQuery { + return filterQuery({ + ChangedTime: valuesToCompareEnum(date, comparator, max_date) + }); + } + + public static createdTime(date: Date, comparator: Comparator, max_date: Date): FilterQuery { + return filterQuery({ + CreatedTime: valuesToCompareEnum(date, comparator, max_date) + }); + } + + public static tagCount(count: number, comparator: Comparator, max_count: number): FilterQuery { + return filterQuery({ + TagCount: valuesToCompareEnum(count, comparator, max_count) + }); + } + + public static contentDescriptor(descriptor: string): FilterQuery { + return filterQuery({ Cd: descriptor }); + } + + public static fileId(id: number): FilterQuery { + return filterQuery({ Id: id }); + } + + public static buildFilterFromString(filterStr: string): FilterQuery { + filterStr = filterStr.trim(); + + if (filterStr.startsWith(".")) { + const cleanFilter = filterStr.replace(/^\./, ""); + const parsedPropertyFilter = this.parsePropertyFilterQuery(cleanFilter); + if (parsedPropertyFilter) { + return parsedPropertyFilter; + } + } else if (filterStr.startsWith("-")) { + const tag = filterStr.replace(/^-/, "").trim(); + return this.tag(tag, true); + } + + return this.tag(filterStr, false); + } + + private static parsePropertyFilterQuery(expression: string): FilterQuery | undefined { + let propertyName = ""; + let compareValue = ""; + let rawComparator = ""; + let comparatorStarted = false; + let valueStarted = false; + + for (const char of expression) { + console.log(char); + if (!valueStarted) { + switch (char) { + case " ": + break; + case "=": + case "!": + case ">": + case "<": + rawComparator += char; + comparatorStarted = true; + break; + default: + valueStarted = comparatorStarted; + if (valueStarted) { + compareValue += char; + } else { + propertyName += char; + } + } + } else { + compareValue += char; + } + } + + return this.parseQueryFromParts(propertyName, rawComparator, compareValue); + } + + private static parseQueryFromParts( + propertyName: string, + rawComparator: string, + compareValue: string + ): FilterQuery | undefined { + const property = this.parsePropertyName(propertyName); + const comparator = this.parseComparator(rawComparator); + console.log("Parts: ", propertyName, rawComparator, compareValue); + + if (property && comparator) { + let value; + switch (property) { + case "Status": + value = parseStatus(compareValue); + if (comparator === "Equal" && value != undefined) { + return this.status(value); + } + break; + case "FileSize": + value = this.parsePropertyValue(compareValue, parseNumber); + if (value != undefined) { + return this.fileSize(value[0], comparator, value[1]); + } + break; + case "ImportedTime": + value = this.parsePropertyValue(compareValue, parseDate); + if (value != undefined) { + return this.importedTime(value[0], comparator, value[1]); + } + + break; + case "ChangedTime": + value = this.parsePropertyValue(compareValue, parseDate); + if (value != undefined) { + return this.changedTime(value[0], comparator, value[1]); + } + break; + case "CreatedTime": + value = this.parsePropertyValue(compareValue, parseDate); + if (value != undefined) { + return this.createdTime(value[0], comparator, value[1]); + } + break; + case "TagCount": + value = this.parsePropertyValue(compareValue, parseNumber); + if (value != undefined) { + return this.tagCount(value[0], comparator, value[1]); + } + break; + case "Cd": + if (compareValue) { + return this.contentDescriptor(compareValue); + } + break; + case "Id": + value = parseNumber(compareValue); + + if (value != undefined) { + return this.fileId(value); + } + + break; + } + } + + return undefined; + } + + private static parseComparator(comparatorStr: string): Comparator | undefined { + switch (comparatorStr) { + case "=": + case "==": + return "Equal"; + case "<": + return "Less"; + case ">": + return "Greater"; + default: + return; + } + } + + private static parsePropertyName(nameStr: string): PropertyType | undefined { + switch (nameStr.toLowerCase().replace(/-_/g, "")) { + case "status": + return "Status"; + case "filesize": + return "FileSize"; + case "importedat": + case "importeddate": + case "importedtime": + return "ImportedTime"; + case "changedat": + case "changeddate": + case "changedtime": + return "ChangedTime"; + case "createdat": + case "createddate": + case "createdtime": + return "CreatedTime"; + case "tagcount": + return "TagCount"; + case "cd": + case "contentdescriptor": + return "Cd"; + case "id": + case "fileid": + return "Id"; + default: + return; + } + } + + private static parsePropertyValue( + valueStr: string, + parseFn: (valueStr: string) => T | undefined + ): T[] | undefined { + const [firstValue, secondValue] = valueStr.split(" "); + if (secondValue != undefined) { + const firstValueParsed = parseFn(firstValue); + const secondValueParsed = parseFn(secondValue); + + if (firstValueParsed && secondValueParsed) { + return [firstValueParsed, secondValueParsed]; + } + } else { + const value = parseFn(firstValue); + return value != undefined ? [value] : undefined; + } + + return; + } +} + +function filterQuery(propertyQuery: PropertyQuery): FilterQuery { + return { Property: propertyQuery }; +} + +function valuesToCompareEnum(min_value: T, comparator: Comparator, max_value?: T): ValueComparator { + switch (comparator) { + case "Less": + return { Less: min_value }; + case "Equal": + return { Equal: min_value }; + case "Greater": + return { Greater: min_value }; + case "Between": + return { Between: [min_value, max_value!] }; + } +} + +function parseNumber(value: string): number | undefined { + const num = Number(value); + return isNaN(num) ? undefined : num; +} + +function parseDate(value: string): Date | undefined { + const date = Date.parse(value); + + if (isNaN(date)) { + return undefined; + } + return new Date(date); +} + +function parseStatus(value: string): FileStatus | undefined { + switch (value.toLowerCase()) { + case "imported": + return "Imported"; + case "archived": + return "Archived"; + case "deleted": + return "Deleted"; + default: + return undefined; + } +} diff --git a/mediarepo-ui/src/api/models/SearchFilters.ts b/mediarepo-ui/src/api/models/SearchFilters.ts new file mode 100644 index 0000000..6a96558 --- /dev/null +++ b/mediarepo-ui/src/api/models/SearchFilters.ts @@ -0,0 +1,75 @@ +import {FilterExpression, FilterQuery} from "../api-types/files"; +import * as deepEqual from "fast-deep-equal"; + +export class SearchFilters { + constructor(private filters: FilterExpression[]) { + } + + public get length() { + return this.filters.length; + } + + public getFilters(): FilterExpression[] { + return this.filters; + } + + public hasFilter(expression: FilterExpression): boolean { + return !!this.filters.find(f => deepEqual(f, expression)); + } + + public addFilter(filter: FilterQuery, index: number) { + this.filters = [...this.filters.slice( + 0, + index + ), { Query: filter }, ...this.filters.slice(index)]; + } + + public appendFilter(filter: FilterQuery) { + this.filters.push({ Query: filter }); + } + + public removeFilter(filterToRemove: FilterExpression) { + this.filters = this.filters.filter(f => !deepEqual(f, filterToRemove)); + } + + public removeFilterAtIndex(index: number) { + this.filters.splice(index, 1); + } + + public appendSubfilter(filter: FilterQuery, index: number) { + const expressionEntry = this.filters[index]; + + if (expressionEntry && "OrExpression" in expressionEntry) { + expressionEntry["OrExpression"]!.push(filter); + } else { + const otherQuery = expressionEntry["Query"]!; + let entry = expressionEntry as unknown as { OrExpression: FilterQuery[], Query: undefined }; + entry["Query"] = undefined; + entry["OrExpression"] = [otherQuery, filter]; + } + } + + public removeSubfilter(queryToRemove: FilterQuery) { + let index = this.filters.findIndex(f => { + if ("Query" in f) { + return false; + } else { + f["OrExpression"] = f["OrExpression"]!.filter(q => !deepEqual(q, queryToRemove)); + return (f["OrExpression"]!.length === 0); + } + }); + this.filters.splice(index); + } + + public removeSubfilterAtIndex(index: number, subindex: number) { + const filterEntry = this.filters[index]; + + if (filterEntry && "OrExpression" in filterEntry) { + filterEntry["OrExpression"]!.splice(subindex, 1); + + if (filterEntry["OrExpression"]!.length === 0) { + this.removeFilterAtIndex(index); + } + } + } +} diff --git a/mediarepo-ui/src/app/app.component-theme.scss b/mediarepo-ui/src/app/app.component-theme.scss index eff2626..e43e010 100644 --- a/mediarepo-ui/src/app/app.component-theme.scss +++ b/mediarepo-ui/src/app/app.component-theme.scss @@ -1,5 +1,5 @@ @use 'sass:map'; -@use '~@angular/material' as mat; +@use '@angular/material' as mat; @mixin color($theme) { $color-config: mat.get-color-config($theme); diff --git a/mediarepo-ui/src/app/components/core/repositories-tab/repositories-tab.component.ts b/mediarepo-ui/src/app/components/core/repositories-tab/repositories-tab.component.ts index bf80d01..bbaf6cc 100644 --- a/mediarepo-ui/src/app/components/core/repositories-tab/repositories-tab.component.ts +++ b/mediarepo-ui/src/app/components/core/repositories-tab/repositories-tab.component.ts @@ -1,22 +1,14 @@ import {AfterViewInit, Component, OnInit} from "@angular/core"; import {Repository} from "../../../../api/models/Repository"; -import { - RepositoryService -} from "../../../services/repository/repository.service"; +import {RepositoryService} from "../../../services/repository/repository.service"; import {MatDialog, MatDialogRef} from "@angular/material/dialog"; -import { - DownloadDaemonDialogComponent -} from "./download-daemon-dialog/download-daemon-dialog.component"; +import {DownloadDaemonDialogComponent} from "./download-daemon-dialog/download-daemon-dialog.component"; import { AddRepositoryDialogComponent } from "../../shared/repository/repository/add-repository-dialog/add-repository-dialog.component"; -import { - ErrorBrokerService -} from "../../../services/error-broker/error-broker.service"; +import {ErrorBrokerService} from "../../../services/error-broker/error-broker.service"; import {BehaviorSubject} from "rxjs"; -import { - BusyDialogComponent -} from "../../shared/app-common/busy-dialog/busy-dialog.component"; +import {BusyDialogComponent} from "../../shared/app-common/busy-dialog/busy-dialog.component"; import {JobService} from "../../../services/job/job.service"; import {StateService} from "../../../services/state/state.service"; @@ -83,7 +75,7 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit { await this.repoService.loadRepositories(); await this.stateService.loadState(); dialogContext.dialog.close(true); - } catch (err) { + } catch (err: any) { this.errorBroker.showError(err); dialogContext.message.next( "Failed to open repository: " + err.toString()); @@ -92,6 +84,22 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit { } } + public openAddRepositoryDialog() { + this.dialog.open(AddRepositoryDialogComponent, { + disableClose: true, + minWidth: "30%", + minHeight: "30%", + }); + } + + public async onOpenRepository(repository: Repository) { + if (!repository.local) { + await this.selectRepository(repository); + } else { + await this.startDaemonAndSelectRepository(repository); + } + } + private async forceCloseRepository() { try { await this.repoService.closeSelectedRepository(); @@ -132,23 +140,17 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit { } }); - return {message: dialogMessage, dialog}; - } - - public openAddRepositoryDialog() { - this.dialog.open(AddRepositoryDialogComponent, { - disableClose: true, - minWidth: "30%", - minHeight: "30%", - }); + return { message: dialogMessage, dialog }; } private async checkAndPromptDaemonExecutable() { if (!await this.repoService.checkDameonConfigured()) { - const result = await this.dialog.open(DownloadDaemonDialogComponent, + const result = await this.dialog.open( + DownloadDaemonDialogComponent, { disableClose: true, - }).afterClosed().toPromise(); + } + ).afterClosed().toPromise(); if (result) { // recursion avoidance setTimeout( @@ -156,12 +158,4 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit { } } } - - public async onOpenRepository(repository: Repository) { - if (!repository.local) { - await this.selectRepository(repository); - } else { - await this.startDaemonAndSelectRepository(repository); - } - } } diff --git a/mediarepo-ui/src/app/components/shared/app-common/app-common.module.ts b/mediarepo-ui/src/app/components/shared/app-common/app-common.module.ts index b133e42..780847b 100644 --- a/mediarepo-ui/src/app/components/shared/app-common/app-common.module.ts +++ b/mediarepo-ui/src/app/components/shared/app-common/app-common.module.ts @@ -1,10 +1,6 @@ import {NgModule} from "@angular/core"; -import { - ConfirmDialogComponent -} from "./confirm-dialog/confirm-dialog.component"; -import { - BusyIndicatorComponent -} from "./busy-indicator/busy-indicator.component"; +import {ConfirmDialogComponent} from "./confirm-dialog/confirm-dialog.component"; +import {BusyIndicatorComponent} from "./busy-indicator/busy-indicator.component"; import {ContextMenuComponent} from "./context-menu/context-menu.component"; import {CommonModule} from "@angular/common"; import {NgIconsModule} from "@ng-icons/core"; @@ -12,16 +8,10 @@ import {MatProgressSpinnerModule} from "@angular/material/progress-spinner"; import {MatButtonModule} from "@angular/material/button"; import {MatDialogModule} from "@angular/material/dialog"; import {MatMenuModule} from "@angular/material/menu"; -import { - ContentAwareImageComponent -} from "./content-aware-image/content-aware-image.component"; -import { - InputReceiverDirective -} from "./input-receiver/input-receiver.directive"; -import { - MetadataEntryComponent -} from "./metadata-entry/metadata-entry.component"; -import { BusyDialogComponent } from './busy-dialog/busy-dialog.component'; +import {ContentAwareImageComponent} from "./content-aware-image/content-aware-image.component"; +import {InputReceiverDirective} from "./input-receiver/input-receiver.directive"; +import {MetadataEntryComponent} from "./metadata-entry/metadata-entry.component"; +import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component"; @NgModule({ diff --git a/mediarepo-ui/src/app/components/shared/app-common/busy-dialog/busy-dialog.component.spec.ts b/mediarepo-ui/src/app/components/shared/app-common/busy-dialog/busy-dialog.component.spec.ts index c753f30..0683835 100644 --- a/mediarepo-ui/src/app/components/shared/app-common/busy-dialog/busy-dialog.component.spec.ts +++ b/mediarepo-ui/src/app/components/shared/app-common/busy-dialog/busy-dialog.component.spec.ts @@ -1,25 +1,25 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import {ComponentFixture, TestBed} from "@angular/core/testing"; -import { BusyDialogComponent } from './busy-dialog.component'; +import {BusyDialogComponent} from "./busy-dialog.component"; -describe('BusyDialogComponent', () => { - let component: BusyDialogComponent; - let fixture: ComponentFixture; +describe("BusyDialogComponent", () => { + let component: BusyDialogComponent; + let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ BusyDialogComponent ] - }) - .compileComponents(); - }); + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BusyDialogComponent] + }) + .compileComponents(); + }); - beforeEach(() => { - fixture = TestBed.createComponent(BusyDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(BusyDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it("should create", () => { + expect(component).toBeTruthy(); + }); }); diff --git a/mediarepo-ui/src/app/components/shared/app-common/context-menu/context-menu.component.ts b/mediarepo-ui/src/app/components/shared/app-common/context-menu/context-menu.component.ts index bc35f97..435d46e 100644 --- a/mediarepo-ui/src/app/components/shared/app-common/context-menu/context-menu.component.ts +++ b/mediarepo-ui/src/app/components/shared/app-common/context-menu/context-menu.component.ts @@ -11,7 +11,7 @@ export class ContextMenuComponent { public x: string = "0"; public y: string = "0"; - @ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger + @ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger; constructor() { } diff --git a/mediarepo-ui/src/app/components/shared/file/file-multiview/file-grid/file-grid.component.ts b/mediarepo-ui/src/app/components/shared/file/file-multiview/file-grid/file-grid.component.ts index 971dd1f..1fc6d28 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-multiview/file-grid/file-grid.component.ts +++ b/mediarepo-ui/src/app/components/shared/file/file-multiview/file-grid/file-grid.component.ts @@ -37,7 +37,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { partitionedGridEntries: Selectable[][] = []; private shiftClicked = false; private ctrlClicked = false; - private gridEntries: Selectable[] = [] + private gridEntries: Selectable[] = []; constructor( private tabService: TabService, @@ -102,6 +102,55 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { await this.fileService.deleteThumbnails(file); } + public focus() { + this.inner.nativeElement.focus(); + } + + public handleKeydownEvent(event: KeyboardEvent) { + this.shiftClicked ||= event.shiftKey; + this.ctrlClicked ||= event.ctrlKey; + + switch (event.key) { + case "ArrowRight": + this.handleArrowSelect("right"); + break; + case "ArrowLeft": + this.handleArrowSelect("left"); + break; + case "ArrowDown": + this.handleArrowSelect("down"); + break; + case "ArrowUp": + this.handleArrowSelect("up"); + break; + case "PageDown": + this.pageDown(); + break; + case "PageUp": + this.pageUp(); + break; + case "a": + case "A": + if (this.shiftClicked && this.ctrlClicked) { + this.selectNone(); + } else if (this.ctrlClicked) { + event.preventDefault(); + this.selectAll(); + } + break; + case "Enter": + if (this.selectedEntries.length === 1) { + this.fileOpenEvent.emit(this.selectedEntries[0].data); + } + break; + } + } + + public handleKeyupEvent(event: KeyboardEvent) { + this.shiftClicked = event.shiftKey ? false : this.shiftClicked; + this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked; + } + private setPartitionedGridEntries() { this.partitionedGridEntries = []; let scrollToIndex = -1; @@ -109,8 +158,10 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { for (let i = 0; i < (Math.ceil( this.gridEntries.length / this.columns)); i++) { - const entries = this.gridEntries.slice(i * this.columns, - Math.min(this.gridEntries.length, (i + 1) * this.columns)); + const entries = this.gridEntries.slice( + i * this.columns, + Math.min(this.gridEntries.length, (i + 1) * this.columns) + ); this.partitionedGridEntries.push(entries); const preselectedEntry = entries.find( @@ -195,7 +246,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { selectedIndex += this.columns; break; case "left": - selectedIndex --; + selectedIndex--; break; case "right": selectedIndex++; @@ -221,61 +272,12 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit { offsetTop = this.virtualScroll.measureScrollOffset("top"); if (contentOffset < offsetTop + (viewportSize / 2)) { - this.virtualScroll.scrollToOffset((offsetTop + 130) - viewportSize/ 2); + this.virtualScroll.scrollToOffset((offsetTop + 130) - viewportSize / 2); } } } } - public focus() { - this.inner.nativeElement.focus(); - } - - public handleKeydownEvent(event: KeyboardEvent) { - this.shiftClicked ||= event.shiftKey; - this.ctrlClicked ||= event.ctrlKey; - - switch (event.key) { - case "ArrowRight": - this.handleArrowSelect("right"); - break; - case "ArrowLeft": - this.handleArrowSelect("left"); - break; - case "ArrowDown": - this.handleArrowSelect("down"); - break; - case "ArrowUp": - this.handleArrowSelect("up"); - break; - case "PageDown": - this.pageDown(); - break; - case "PageUp": - this.pageUp(); - break; - case "a": - case "A": - if (this.shiftClicked && this.ctrlClicked) { - this.selectNone(); - } else if (this.ctrlClicked) { - event.preventDefault(); - this.selectAll(); - } - break; - case "Enter": - if (this.selectedEntries.length === 1) { - this.fileOpenEvent.emit(this.selectedEntries[0].data); - } - break; - } - } - - public handleKeyupEvent(event: KeyboardEvent) { - this.shiftClicked = event.shiftKey? false : this.shiftClicked; - this.ctrlClicked = event.ctrlKey? false : this.ctrlClicked; - } - private pageDown() { if (this.virtualScroll) { const offsetTop = this.virtualScroll.measureScrollOffset("top"); diff --git a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts index 8979313..dbf6147 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts +++ b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts @@ -1,10 +1,4 @@ -import { - AfterViewInit, - Component, - Input, - OnChanges, - SimpleChanges -} from "@angular/core"; +import {AfterViewInit, Component, Input, OnChanges, SimpleChanges} from "@angular/core"; import {File} from "../../../../../api/models/File"; import {FileService} from "../../../../services/file/file.service"; import {FileHelper} from "../../../../services/file/file.helper"; @@ -21,9 +15,9 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit { public thumbUrl: SafeResourceUrl | undefined; - private supportedThumbnailTypes = ["image", "video"] + private supportedThumbnailTypes = ["image", "video"]; - constructor( private fileService: FileService) { + constructor(private fileService: FileService) { } public async ngAfterViewInit() { @@ -33,7 +27,8 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit { public async ngOnChanges(changes: SimpleChanges) { if (changes["file"]) { this.thumbUrl = this.fileService.buildThumbnailUrl(this.file, - 250, 250); + 250, 250 + ); } } diff --git a/mediarepo-ui/src/app/components/shared/sidebar/file-import/filesystem-import/filesystem-import.component.ts b/mediarepo-ui/src/app/components/shared/sidebar/file-import/filesystem-import/filesystem-import.component.ts index 9959a37..35e7767 100644 --- a/mediarepo-ui/src/app/components/shared/sidebar/file-import/filesystem-import/filesystem-import.component.ts +++ b/mediarepo-ui/src/app/components/shared/sidebar/file-import/filesystem-import/filesystem-import.component.ts @@ -1,8 +1,6 @@ import {Component, EventEmitter, Output} from "@angular/core"; import {ImportService} from "../../../../../services/import/import.service"; -import { - ErrorBrokerService -} from "../../../../../services/error-broker/error-broker.service"; +import {ErrorBrokerService} from "../../../../../services/error-broker/error-broker.service"; import {AddFileOptions} from "../../../../../models/AddFileOptions"; import {File} from "../../../../../../api/models/File"; import {DialogFilter} from "@tauri-apps/api/dialog"; @@ -26,12 +24,12 @@ export class FilesystemImportComponent { name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }, - {name: "Videos", extensions: ["mp4", "mkv", "wmv", "avi", "webm"]}, - {name: "Audio", extensions: ["mp3", "ogg", "wav", "flac", "aac"]}, - {name: "Documents", extensions: ["pdf", "doc", "docx", "odf"]}, - {name: "Text", extensions: ["txt", "md"]}, - {name: "All", extensions: ["*"]} - ] + { name: "Videos", extensions: ["mp4", "mkv", "wmv", "avi", "webm"] }, + { name: "Audio", extensions: ["mp3", "ogg", "wav", "flac", "aac"] }, + { name: "Documents", extensions: ["pdf", "doc", "docx", "odf"] }, + { name: "Text", extensions: ["txt", "md"] }, + { name: "All", extensions: ["*"] } + ]; public resolving = false; @@ -61,8 +59,10 @@ export class FilesystemImportComponent { for (const file of this.files) { try { - const resultFile = await this.importService.addLocalFile(file, - this.importOptions); + const resultFile = await this.importService.addLocalFile( + file, + this.importOptions + ); this.fileImported.emit(resultFile); } catch (err) { console.log(err); diff --git a/mediarepo-ui/src/app/components/shared/sidebar/file-search/file-search.component.html b/mediarepo-ui/src/app/components/shared/sidebar/file-search/file-search.component.html index f7b6335..2987507 100644 --- a/mediarepo-ui/src/app/components/shared/sidebar/file-search/file-search.component.html +++ b/mediarepo-ui/src/app/components/shared/sidebar/file-search/file-search.component.html @@ -4,8 +4,11 @@
-
{{filter.getDisplayName()}}
+
+ +
@@ -14,7 +17,9 @@
-