Merge pull request #6 from Trivernis/feature/file-status

Feature/file status
pull/4/head
Julius Riegel 2 years ago committed by GitHub
commit 10077d53b0

@ -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

@ -1,52 +1,66 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"quotes": ["warn", "double", {"avoidEscape": true}],
"indent": ["error", 4, {"SwitchCase": 1}]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
],
"quotes": [
"warn",
"double",
{
"avoidEscape": true
}
],
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-unused-expressions": "warn",
"semi": "error"
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}

@ -7,6 +7,9 @@
# Only exists if Bazel was run
/bazel-out
# angular stuff
.angular
# dependencies
/node_modules

@ -1,126 +1,130 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "yarn",
"defaultCollection": "@angular-eslint/schematics"
},
"newProjectRoot": "projects",
"projects": {
"mediarepo-ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/mediarepo-ui",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "10mb"
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "yarn",
"defaultCollection": "@angular-eslint/schematics"
},
"newProjectRoot": "projects",
"projects": {
"mediarepo-ui": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"@schematics/angular:application": {
"strict": true
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "mediarepo-ui:build:production"
},
"development": {
"browserTarget": "mediarepo-ui:build:development"
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/mediarepo-ui",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "10mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": {
"fonts": false,
"styles": false,
"scripts": true
},
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "mediarepo-ui:build:production"
},
"development": {
"browserTarget": "mediarepo-ui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "mediarepo-ui:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "mediarepo-ui:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/purple-green.css",
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "mediarepo-ui"
},
"defaultProject": "mediarepo-ui"
}

@ -1,61 +1,62 @@
{
"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",
"watch-prod": "ng build --watch --configuration production",
"test": "ng test",
"lint": "ng lint",
"tauri": "tauri"
},
"private": true,
"dependencies": {
"@angular/animations": "~13.1.2",
"@angular/cdk": "^13.1.2",
"@angular/common": "~13.1.2",
"@angular/compiler": "~13.1.2",
"@angular/core": "~13.1.2",
"@angular/flex-layout": "^13.0.0-beta.36",
"@angular/forms": "~13.1.2",
"@angular/material": "^13.1.2",
"@angular/platform-browser": "~13.1.2",
"@angular/platform-browser-dynamic": "~13.1.2",
"@angular/router": "~13.1.2",
"@ng-icons/core": "^13.2.1",
"@ng-icons/feather-icons": "^13.2.1",
"@ng-icons/material-icons": "^13.2.1",
"@tauri-apps/api": "^1.0.0-beta.8",
"primeicons": "^5.0.0",
"primeng": "^13.0.4",
"rxjs": "~7.5.2",
"tslib": "^2.3.1",
"w3c-keys": "^1.0.3",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~13.1.3",
"@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.3",
"@angular/compiler-cli": "~13.1.2",
"@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"
}
}

@ -70,9 +70,9 @@ version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -140,9 +140,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "blake3"
version = "1.2.0"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "526c210b4520e416420759af363083471656e819a75e831b8d2c9d5a584f2413"
checksum = "882e99e4a0cb2ae6cb6e442102e8e6b7131718d94110e64c3e6a34ea9b106f37"
dependencies = [
"arrayref",
"arrayvec",
@ -159,6 +159,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d36a02058e76b040de25a4464ba1c80935655595b661505c8b39b664828b95"
dependencies = [
"generic-array",
]
[[package]]
name = "bromine"
version = "0.17.1"
@ -188,9 +197,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.8.0"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899"
[[package]]
name = "byteorder"
@ -356,8 +365,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7606b05842fea68ddcc89e8053b8860ebcb2a0ba8d6abfe3a148e5d5a8d3f0c1"
dependencies = [
"com_macros_support",
"proc-macro2 1.0.35",
"syn 1.0.84",
"proc-macro2 1.0.36",
"syn 1.0.85",
]
[[package]]
@ -366,9 +375,9 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97e9a6d20f4ac8830e309a455d7e9416e65c6af5a97c88c55fbb4c2012e107da"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -476,9 +485,9 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4"
checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa"
dependencies = [
"cfg-if 1.0.0",
"crossbeam-utils",
@ -497,9 +506,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.5"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec02e091aa634e2c3ada4a392989e7c3116673ef0ac5b72232439094d73b7fd"
checksum = "97242a70df9b89a65d0b6df3c4bf5b9ce03c5b7309019777fbde37e7537f8762"
dependencies = [
"cfg-if 1.0.0",
"crossbeam-utils",
@ -510,14 +519,23 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db"
checksum = "cfcae03edb34f947e64acdb1c33ec169824e20657e9ecb61cef6c8c74dcb8120"
dependencies = [
"cfg-if 1.0.0",
"lazy_static",
]
[[package]]
name = "crypto-common"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d6b536309245c849479fba3da410962a43ed8e51c26b729208ec0ac2798d0"
dependencies = [
"generic-array",
]
[[package]]
name = "cssparser"
version = "0.27.2"
@ -529,10 +547,10 @@ dependencies = [
"itoa 0.4.8",
"matches",
"phf 0.8.0",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"smallvec",
"syn 1.0.84",
"syn 1.0.85",
]
[[package]]
@ -541,8 +559,8 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfae75de57f2b2e85e8768c3ea840fd159c8f33e2b6522c7835b7abac81be16e"
dependencies = [
"quote 1.0.10",
"syn 1.0.84",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -569,10 +587,10 @@ checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b"
dependencies = [
"fnv",
"ident_case",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"strsim",
"syn 1.0.84",
"syn 1.0.85",
]
[[package]]
@ -582,8 +600,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72"
dependencies = [
"darling_core",
"quote 1.0.10",
"syn 1.0.84",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -606,17 +624,6 @@ dependencies = [
"byteorder",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
]
[[package]]
name = "derive_more"
version = "0.99.17"
@ -624,19 +631,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321"
dependencies = [
"convert_case",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"rustc_version 0.4.0",
"syn 1.0.84",
"syn 1.0.85",
]
[[package]]
name = "digest"
version = "0.9.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
checksum = "b697d66081d42af4fba142d56918a3cb21dc8eb63372c6b85d14f44fb9c5979b"
dependencies = [
"block-buffer",
"crypto-common",
"generic-array",
"subtle",
]
[[package]]
@ -709,9 +719,9 @@ checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]]
name = "embed_plist"
version = "1.2.0"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53dd2e43a7d32952a6054141ee0d75183958620e84e5eab045de362dff13dc99"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "fastrand"
@ -866,9 +876,9 @@ version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -983,9 +993,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "0.14.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
dependencies = [
"typenum",
"version_check",
@ -1004,9 +1014,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c"
dependencies = [
"cfg-if 1.0.0",
"libc",
@ -1085,9 +1095,9 @@ dependencies = [
"heck",
"proc-macro-crate 1.1.0",
"proc-macro-error",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1196,9 +1206,9 @@ dependencies = [
"heck",
"proc-macro-crate 1.1.0",
"proc-macro-error",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1228,20 +1238,20 @@ dependencies = [
"log",
"mac",
"markup5ever",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
name = "http"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1323096b05d41827dadeaee54c9981958c0f94e670bc94ed80037d1a7b8b186b"
checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03"
dependencies = [
"bytes",
"fnv",
"itoa 0.4.8",
"itoa 1.0.1",
]
[[package]]
@ -1489,8 +1499,8 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.20.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=0c897acfd959c776fc10bd8fabdd2eb22b437be3#0c897acfd959c776fc10bd8fabdd2eb22b437be3"
version = "0.27.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=57d85eb3a668a8d4d344b7fbe2403731a16625a7#57d85eb3a668a8d4d344b7fbe2403731a16625a7"
dependencies = [
"async-trait",
"bromine",
@ -1636,9 +1646,9 @@ checksum = "05d1c6307dc424d0f65b9b06e94f88248e6305726b14729fd67a5e47b2dc481d"
dependencies = [
"darling",
"proc-macro-crate 0.1.5",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1710,24 +1720,23 @@ dependencies = [
[[package]]
name = "num_enum"
version = "0.5.5"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "085fe377a4b2805c0fbc09484415ec261174614b7f080b0e0d520456ac421a67"
checksum = "720d3ea1055e4e4574c0c0b0f8c3fd4f24c4cdaf465948206dea090b57b526ad"
dependencies = [
"derivative",
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.5.5"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5249369707a1e07b39f78d98c8f34e00aca7dcb053812fdbb5ad7be82c1bba38"
checksum = "0d992b768490d7fe0d8586d9b5745f6c49f557da6d81dc982b1d167ad4edbb21"
dependencies = [
"proc-macro-crate 1.1.0",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1791,9 +1800,9 @@ dependencies = [
[[package]]
name = "openssl-probe"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
@ -1808,6 +1817,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "os_pipe"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb233f06c2307e1f5ce2ecad9f8121cffbbee2c95428f44ea85222e460d0d213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "pango"
version = "0.14.8"
@ -1956,9 +1975,9 @@ dependencies = [
"phf_generator 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1970,9 +1989,9 @@ dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -1995,9 +2014,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
version = "0.2.7"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d31d11c69a6b52a174b42bdc0c30e5e11670f90788b2c471c31c1d17d449443"
checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c"
[[package]]
name = "pin-utils"
@ -2037,9 +2056,9 @@ dependencies = [
[[package]]
name = "ppv-lite86"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba"
checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
[[package]]
name = "precomputed-hash"
@ -2073,9 +2092,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
"version_check",
]
@ -2085,8 +2104,8 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"version_check",
]
@ -2107,9 +2126,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.35"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "392a54546fda6b7cc663379d0e6ce8b324cf88aecc5a499838e1be9781bdce2e"
checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
dependencies = [
"unicode-xid 0.2.2",
]
@ -2125,11 +2144,11 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.10"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d"
dependencies = [
"proc-macro2 1.0.35",
"proc-macro2 1.0.36",
]
[[package]]
@ -2193,7 +2212,7 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
dependencies = [
"getrandom 0.2.3",
"getrandom 0.2.4",
]
[[package]]
@ -2282,7 +2301,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom 0.2.3",
"getrandom 0.2.4",
"redox_syscall",
]
@ -2474,29 +2493,29 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.132"
version = "1.0.133"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b9875c23cf305cd1fd7eb77234cbb705f21ea6a72c637a5c6db5fe4b8e7f008"
checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.132"
version = "1.0.133"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc0db5cb2556c0e558887d9bbdcf6ac4471e83ff66cf696e5419024d1606276"
checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
name = "serde_json"
version = "1.0.73"
version = "1.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcbd0344bc6533bc7ec56df11d42fb70f1b912351c0825ccb7211b59d8af7cf5"
checksum = "ee2bb9cd061c5865d345bb02ca49fcef1391741b672b54a0bf7b679badec3142"
dependencies = [
"itoa 1.0.1",
"ryu",
@ -2531,9 +2550,9 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98d0516900518c29efa217c298fa1f4e6c6ffc85ae29fd7f4ee48f176e1a9ed5"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -2567,6 +2586,16 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6be9f7d5565b1483af3e72975e2dee33879b3b86bd48c0929fccf6585d79e65a"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.0"
@ -2590,9 +2619,9 @@ checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5"
[[package]]
name = "smallvec"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
[[package]]
name = "soup-sys"
@ -2646,8 +2675,8 @@ checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97"
dependencies = [
"phf_generator 0.8.0",
"phf_shared 0.8.0",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
]
[[package]]
@ -2675,9 +2704,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c"
dependencies = [
"heck",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -2687,11 +2716,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec"
dependencies = [
"heck",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
name = "subtle"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"
version = "0.15.44"
@ -2705,12 +2740,12 @@ dependencies = [
[[package]]
name = "syn"
version = "1.0.84"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b"
checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"unicode-xid 0.2.2",
]
@ -2818,6 +2853,7 @@ dependencies = [
"ignore",
"once_cell",
"open",
"os_pipe",
"percent-encoding",
"rand 0.8.4",
"raw-window-handle 0.3.4",
@ -2826,6 +2862,7 @@ dependencies = [
"serde",
"serde_json",
"serde_repr",
"shared_child",
"state",
"tar",
"tauri-macros",
@ -2847,8 +2884,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c9c9a9bea25b9d6f5845b8662e18447e17218f99860cab37e39e2b57a9fcd49"
dependencies = [
"anyhow",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"serde_json",
"tauri-utils",
"winres",
@ -2862,8 +2899,8 @@ checksum = "1663739ab53e281919676f216fb56a031104d0d2cd1a2dd5b012d279bcdb0ea4"
dependencies = [
"blake3",
"kuchiki",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"regex",
"serde",
"serde_json",
@ -2879,9 +2916,9 @@ version = "1.0.0-beta.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bddf9f5868402323f35ef94fa6ab1d5d10b29aea9de598d829723aa1db5693b4"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
"tauri-codegen",
]
@ -2929,8 +2966,8 @@ dependencies = [
"html5ever",
"kuchiki",
"phf 0.10.1",
"proc-macro2 1.0.35",
"quote 1.0.10",
"proc-macro2 1.0.36",
"quote 1.0.14",
"serde",
"serde_json",
"thiserror",
@ -2940,13 +2977,13 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.2.0"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22"
checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
dependencies = [
"cfg-if 1.0.0",
"fastrand",
"libc",
"rand 0.8.4",
"redox_syscall",
"remove_dir_all",
"winapi",
@ -2984,9 +3021,9 @@ version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -3068,9 +3105,9 @@ version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
]
[[package]]
@ -3095,9 +3132,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.4"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5e6136799e1079699e0d9784c883e03af55cf6a1bee48fe1d79ca552c1bc36f"
checksum = "5d81bfa81424cc98cb034b837c985b7a290f592e5b4322f353f94a0ab0f9f594"
dependencies = [
"ansi_term",
"lazy_static",
@ -3196,7 +3233,7 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.3",
"getrandom 0.2.4",
]
[[package]]
@ -3219,9 +3256,9 @@ checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b"
[[package]]
name = "version_check"
version = "0.9.3"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "waker-fn"
@ -3271,9 +3308,9 @@ dependencies = [
"bumpalo",
"lazy_static",
"log",
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
"wasm-bindgen-shared",
]
@ -3295,7 +3332,7 @@ version = "0.2.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9"
dependencies = [
"quote 1.0.10",
"quote 1.0.14",
"wasm-bindgen-macro-support",
]
@ -3305,9 +3342,9 @@ version = "0.2.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab"
dependencies = [
"proc-macro2 1.0.35",
"quote 1.0.10",
"syn 1.0.84",
"proc-macro2 1.0.36",
"quote 1.0.14",
"syn 1.0.85",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -3515,18 +3552,18 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.9.1+zstd.1.5.1"
version = "0.9.2+zstd.1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "538b8347df9257b7fbce37677ef7535c00a3c7bf1f81023cc328ed7fe4b41de8"
checksum = "2390ea1bf6c038c39674f22d95f0564725fc06034a47129179810b2fc58caa54"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "4.1.2+zstd.1.5.1"
version = "4.1.3+zstd.1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb4cfe2f6e6d35c5d27ecd9d256c4b6f7933c4895654917460ec56c29336cc1"
checksum = "e99d81b99fb3c2c2c794e3fe56c305c63d5173a16a46b5850b07c935ffc7db79"
dependencies = [
"libc",
"zstd-sys",

@ -2,7 +2,7 @@
name = "app"
version = "0.12.0"
description = "A Tauri App"
authors = [ "you" ]
authors = ["you"]
license = ""
repository = ""
default-run = "app"
@ -14,20 +14,23 @@ tauri-build = { version = "^1.0.0-beta.4" }
[dependencies]
serde_json = "^1.0"
serde = { version = "^1.0", features = [ "derive" ] }
tauri = { version = "^1.0.0-beta.8", features = ["dialog-all", "path-all", "shell-open"] }
serde = { version = "^1.0", features = ["derive"] }
thiserror = "^1.0.30"
typemap_rev = "^0.1.5"
[dependencies.tauri]
version = "^1.0.0-beta.8"
features = ["dialog-all", "path-all", "shell-all"]
[dependencies.tracing-subscriber]
version = "^0.3.0"
features = [ "env-filter" ]
features = ["env-filter"]
[dependencies.mediarepo-api]
git = "https://github.com/Trivernis/mediarepo-api.git"
rev = "0c897acfd959c776fc10bd8fabdd2eb22b437be3"
features = [ "tauri-plugin" ]
rev = "57d85eb3a668a8d4d344b7fbe2403731a16625a7"
features = ["tauri-plugin"]
[features]
default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

@ -53,11 +53,11 @@
"dialog": {
"all": true
},
"path": {
"shell": {
"all": true
},
"shell": {
"open": true
"path": {
"all": true
}
},
"windows": [

@ -0,0 +1,180 @@
import {FileBasicData, FileMetadata, FileOsMetadata} from "./api-types/files";
import {invoke} from "@tauri-apps/api/tauri";
import {ApiFunction} from "./api-types/functions";
import {
AddLocalFileREquest,
AddRepositoryRequest,
ChangeFileTagsRequest,
CheckDaemonRunningRequest,
CheckLocalRepositoryExistsRequest,
CreateTagsRequest,
DeleteFileRequest,
DeleteRepositoryRequest,
DeleteThumbnailsRequest,
FindFilesRequest,
GetFileMetadataRequest,
GetSizeRequest,
GetTagsForFilesRequest,
InitRepositoryRequest,
ReadFileRequest,
RemoveRepositoryRequest,
ResolvePathsToFilesRequest,
RunJobRequest,
SaveFileRequest,
SelectRepositoryRequest,
SetFrontendStateRequest,
StartDaemonRequest,
UpdateFileNameRequest,
UpdateFileStatusRequest
} from "./api-types/requests";
import {RepositoryData, RepositoryMetadata, SizeMetadata} from "./api-types/repo";
import {NamespaceData, TagData} from "./api-types/tags";
import {ShortCache} from "./ShortCache";
export class MediarepoApi {
public static async hasExecutable(): Promise<boolean> {
return this.invokePlugin(ApiFunction.HasExecutable);
}
public static async getRepositories(): Promise<RepositoryData[]> {
return this.invokePlugin(ApiFunction.GetRepositories);
}
public static async selectRepository(request: SelectRepositoryRequest): Promise<void> {
return this.invokePlugin(ApiFunction.SelectRepository, request);
}
public static async disconnectRepository(): Promise<void> {
return this.invokePlugin(ApiFunction.DisconnectRepository);
}
public static async closeLocalRepository(): Promise<void> {
return this.invokePlugin(ApiFunction.CloseLocalRepository);
}
public static async addRepository(request: AddRepositoryRequest): Promise<RepositoryData[]> {
return this.invokePlugin(ApiFunction.AddRepository, request);
}
public static async checkDaemonRunning(request: CheckDaemonRunningRequest): Promise<boolean> {
return this.invokePlugin(ApiFunction.CheckDaemonRunning, request);
}
public static async checkLocalRepositoryExists(request: CheckLocalRepositoryExistsRequest): Promise<boolean> {
return this.invokePlugin(ApiFunction.CheckLocalRepositoryExists, request);
}
public static async removeRepository(request: RemoveRepositoryRequest): Promise<void> {
return this.invokePlugin(ApiFunction.RemoveRepository, request);
}
public static async deleteRepository(request: DeleteRepositoryRequest): Promise<void> {
return this.invokePlugin(ApiFunction.DeleteRepository, request);
}
public static async startDaemon(request: StartDaemonRequest): Promise<void> {
return this.invokePlugin(ApiFunction.StartDaemon, request);
}
public static async initRepository(request: InitRepositoryRequest): Promise<void> {
return this.invokePlugin(ApiFunction.InitRepository, request);
}
public static async getRepositoryMetadata(): Promise<RepositoryMetadata> {
return this.invokePlugin(ApiFunction.GetRepoMetadata);
}
public static async getSize(request: GetSizeRequest): Promise<SizeMetadata> {
return this.invokePlugin(ApiFunction.GetSize, request);
}
public static async getActiveRepository(): Promise<RepositoryData | undefined> {
return this.invokePlugin(ApiFunction.GetActiveRepository);
}
public static async getAllFiles(): Promise<FileBasicData[]> {
return this.invokePlugin(ApiFunction.GetAllFiles);
}
public static async findFiles(request: FindFilesRequest): Promise<FileBasicData[]> {
return ShortCache.cached(request, () => this.invokePlugin(ApiFunction.FindFiles, request), 5000, "findFiles");
}
public static async getFileMetadata(request: GetFileMetadataRequest): Promise<FileMetadata> {
return this.invokePlugin(ApiFunction.GetFileMetadata, request);
}
public static async updateFileName(request: UpdateFileNameRequest): Promise<FileMetadata> {
return this.invokePlugin(ApiFunction.UpdateFileName, request);
}
public static async updateFileStatus(request: UpdateFileStatusRequest): Promise<FileBasicData> {
return this.invokePlugin(ApiFunction.UpdateFileStatus, request);
}
public static async saveFileLocally(request: SaveFileRequest): Promise<void> {
return this.invokePlugin(ApiFunction.SaveFileLocally, request);
}
public static async deleteThumbnails(request: DeleteThumbnailsRequest): Promise<void> {
return this.invokePlugin(ApiFunction.DeleteThumbnails, request);
}
public static async readFile(request: ReadFileRequest): Promise<number[]> {
return this.invokePlugin(ApiFunction.ReadFile, request);
}
public static async deleteFile(request: DeleteFileRequest): Promise<void> {
return this.invokePlugin(ApiFunction.DeleteFile, request);
}
public static async getAllTags(): Promise<TagData[]> {
return ShortCache.cached("all-tags", () => this.invokePlugin(ApiFunction.GetAllTags), 2000);
}
public static async getAllNamespaces(): Promise<NamespaceData[]> {
return this.invokePlugin(ApiFunction.GetAllNamespace);
}
public static async getTagsForFiles(request: GetTagsForFilesRequest): Promise<TagData[]> {
return ShortCache.cached(
request,
() => this.invokePlugin(ApiFunction.GetTagsForFiles, request),
1000,
"getTagsForFiles"
);
}
public static async createTags(request: CreateTagsRequest): Promise<TagData[]> {
return this.invokePlugin(ApiFunction.CreateTags, request);
}
public static async changeFileTags(request: ChangeFileTagsRequest): Promise<TagData[]> {
return this.invokePlugin(ApiFunction.ChangeFileTags, request);
}
public static async resolvePathsToFiles(request: ResolvePathsToFilesRequest): Promise<FileOsMetadata[]> {
return this.invokePlugin(ApiFunction.ResolvePathsToFiles, request);
}
public static async addLocalFile(request: AddLocalFileREquest): Promise<FileBasicData> {
return this.invokePlugin(ApiFunction.AddLocalFile, request);
}
public static async getFrontendState(): Promise<string> {
return ShortCache.cached("frontend-state", () => this.invokePlugin(ApiFunction.GetFrontendState), 1000);
}
public static async setFrontendState(request: SetFrontendStateRequest): Promise<void> {
return this.invokePlugin(ApiFunction.SetFrontendState, request);
}
public static async runJob(request: RunJobRequest): Promise<void> {
return this.invokePlugin(ApiFunction.RunJob, request);
}
private static async invokePlugin<T>(fn: ApiFunction, args?: any): Promise<T> {
return invoke<T>(`plugin:mediarepo|${fn}`, args);
}
}

@ -0,0 +1,69 @@
type CacheEntry<T> = {
ttl: number,
value: T,
}
const cacheMap: {
[key: string]: CacheEntry<any>
} = {};
export class ShortCache {
public static async cached<T>(
key: any,
producer: () => Promise<T>,
ttl: number = 1000,
prefix: string = ""
): Promise<T> {
const cacheKey = prefix + JSON.stringify(key);
const entry = this.getCacheEntry<T>(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<T>(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();

@ -0,0 +1,82 @@
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 PropertyQuery = PropertyQueryStatus
| PropertyQueryFileSize
| PropertyQueryImportedTime
| PropertyQueryChangedTime
| PropertyQueryCreatedTime
| PropertyQueryTagCount
| PropertyQueryCd
| PropertyQueryId;
export type PropertyQueryStatus = { Status: FileStatus };
export type PropertyQueryFileSize = { FileSize: ValueComparator<number> };
export type PropertyQueryImportedTime = { ImportedTime: ValueComparator<Date> };
export type PropertyQueryChangedTime = { ChangedTime: ValueComparator<Date> };
export type PropertyQueryCreatedTime = { CreatedTime: ValueComparator<Date> };
export type PropertyQueryTagCount = { TagCount: ValueComparator<number> };
export type PropertyQueryCd = { Cd: string };
export type PropertyQueryId = { Id: number };
export type ValueComparator<T> =
{ Less: T }
| { Equal: T }
| { Greater: T }
| { Between: T[] }
export type SortKey = { Namespace: SortNamespace }
| { FileName: SortDirection }
| { FileSize: SortDirection }
| { FileImportedTime: SortDirection }
| { FileChangeTime: SortDirection }
| { FileType: SortDirection };
export type SortNamespace = {
name: string,
direction: SortDirection,
}
export type SortDirection = "Ascending" | "Descending";
export type FileBasicData = {
id: number,
status: FileStatus,
cd: string,
mime_type: string,
};
export type FileStatus = "Imported" | "Archived" | "Deleted";
export type FileMetadata = {
file_id: number,
name?: string,
comment?: string,
creation_time: Date,
change_time: Date,
import_time: Date,
};
export type FileOsMetadata = {
name: string,
path: string,
mime_type: string,
created_at: Date,
modified_at: Date,
};

@ -0,0 +1,42 @@
export enum ApiFunction {
// repository
HasExecutable = "has_executable",
GetRepositories = "get_repositories",
SelectRepository = "select_repository",
DisconnectRepository = "disconnect_repository",
CloseLocalRepository = "close_local_repository",
AddRepository = "add_repository",
CheckDaemonRunning = "check_daemon_running",
CheckLocalRepositoryExists = "check_local_repository_exists",
RemoveRepository = "remove_repository",
DeleteRepository = "delete_repository",
StartDaemon = "start_daemon",
InitRepository = "init_repository",
GetRepoMetadata = "get_repo_metadata",
GetSize = "get_size",
GetActiveRepository = "get_active_repository",
// files
GetAllFiles = "get_all_files",
FindFiles = "find_files",
GetFileMetadata = "get_file_metadata",
UpdateFileName = "update_file_name",
UpdateFileStatus = "update_file_status",
SaveFileLocally = "save_file_locally",
DeleteThumbnails = "delete_thumbnails",
ReadFile = "read_file",
DeleteFile = "delete_file",
// tags
GetAllTags = "get_all_tags",
GetAllNamespace = "get_all_namespaces",
GetTagsForFiles = "get_tags_for_files",
CreateTags = "create_tags",
ChangeFileTags = "change_file_tags",
// import
ResolvePathsToFiles = "resolve_paths_to_files",
AddLocalFile = "add_local_file",
// state
GetFrontendState = "get_frontend_state",
SetFrontendState = "set_frontend_state",
// jobs
RunJob = "run_job",
}

@ -0,0 +1,3 @@
export type JobType = "MigrateContentDescriptors"
| "CalculateSizes"
| "CheckIntegrity";

@ -0,0 +1,22 @@
export type RepositoryMetadata = {
version: string,
file_count: number,
tag_count: number,
namespace_count: number,
mapping_count: number,
hash_count: number,
};
export type SizeMetadata = {
size_type: SizeType,
size: number,
};
export type SizeType = "Total" | "FileFolder" | "ThumbFolder" | "DatabaseFile";
export type RepositoryData = {
name: string,
address?: string,
path?: string,
local: boolean,
}

@ -0,0 +1,106 @@
import {FileOsMetadata, FileStatus, FilterExpression, SortKey} from "./files";
import {RepositoryData, SizeType} from "./repo";
import {JobType} from "./job";
type NameIdentifierRequest = {
name: string
};
type IdIdentifierRequest = {
id: number
};
type RepoPathIdentifier = {
repoPath: string;
}
export type SelectRepositoryRequest = NameIdentifierRequest;
export type AddRepositoryRequest = RepositoryData;
export type CheckLocalRepositoryExistsRequest = {
path: string
};
export type RemoveRepositoryRequest = NameIdentifierRequest;
export type DeleteRepositoryRequest = NameIdentifierRequest;
export type CheckDaemonRunningRequest = {
address: string
};
export type StartDaemonRequest = RepoPathIdentifier;
export type InitRepositoryRequest = RepoPathIdentifier;
export type GetSizeRequest = {
sizeType: SizeType
};
export type FindFilesRequest = {
filters: FilterExpression[],
sortBy: SortKey[]
};
export type UpdateFileNameRequest = {
id: number,
name: string,
};
export type SaveFileRequest = {
id: number,
path: string,
};
export type DeleteThumbnailsRequest = IdIdentifierRequest;
export type ReadFileRequest = {
hash: string,
mimeType: string,
};
export type DeleteFileRequest = IdIdentifierRequest;
export type GetFileMetadataRequest = IdIdentifierRequest;
export type UpdateFileStatusRequest = {
id: number,
status: FileStatus
};
export type GetTagsForFilesRequest = {
cds: string[]
};
export type CreateTagsRequest = {
tags: string[]
};
export type ChangeFileTagsRequest = {
id: number,
addedTags: number[],
removedTags: number[],
};
export type ResolvePathsToFilesRequest = {
paths: string[],
};
export type AddLocalFileREquest = {
metadata: FileOsMetadata,
options: AddFileOptions,
}
type AddFileOptions = {
read_tags_from_txt: boolean,
delete_after_import: boolean,
};
export type SetFrontendStateRequest = {
state: string
};
export type RunJobRequest = {
jobType: JobType,
}

@ -0,0 +1,10 @@
export type TagData = {
id: number,
namespace?: string,
name: string,
};
export type NamespaceData = {
id: number,
name: string,
};

@ -0,0 +1,32 @@
import {FileBasicData, FileStatus} from "../api-types/files";
export class File {
constructor(
private basicData: FileBasicData,
) {
}
public get rawData(): FileBasicData {
return this.basicData;
}
public get id(): number {
return this.basicData.id;
}
public get cd(): string {
return this.basicData.cd;
}
public get status(): FileStatus {
return this.basicData.status;
}
public get mimeType(): string {
return this.basicData.mime_type;
}
public set status(value: FileStatus) {
this.basicData.status = value;
}
}

@ -0,0 +1,332 @@
import {FileStatus, FilterExpression, 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 buildFilterExpressionsFromString(expressionStr: string): FilterExpression | undefined {
const parts = expressionStr.split(/\s+or\s+/gi);
const queries = parts.map(part => this.buildFilterFromString(part)).filter(f => f != undefined) as FilterQuery[];
if (queries.length > 1) {
return { OrExpression: queries };
} else if (queries.length === 1) {
return { Query: queries[0] };
} else {
return undefined;
}
}
public static buildFilterFromString(filterStr: string): FilterQuery | undefined {
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) {
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);
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, parseByteSize);
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<T>(
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<T>(min_value: T, comparator: Comparator, max_value?: T): ValueComparator<T> {
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 parseByteSize(value: string): number | undefined {
const valueMappings: { [key: string]: number } = {
"TiB": 1024 ** 4,
"GiB": 1024 ** 3,
"MiB": 1024 ** 2,
"KiB": 1024,
"TB": 1000 ** 4,
"GB": 1000 ** 3,
"MB": 1000 ** 2,
"KB": 1000
};
const stringValue = value.replace(/TiB|GiB|MiB|KiB|TB|GB|MB|KB$/i, "");
let number = parseNumber(stringValue);
const checkUnit = (unit: string) => value.toLowerCase().includes(unit.toLowerCase());
if (number) {
for (const key of Object.keys(valueMappings)) {
if (checkUnit(key)) {
console.log("key", key, "valueMapping", valueMappings[key]);
number *= valueMappings[key];
console.log("number", number);
break;
}
}
}
return number;
}
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;
}
}

@ -0,0 +1,14 @@
import {NamespaceData} from "../api-types/tags";
export class Namespace {
constructor(private data: NamespaceData) {
}
public get id(): number {
return this.data.id;
}
public get name(): string {
return this.data.name;
}
}

@ -0,0 +1,28 @@
import {RepositoryData} from "../api-types/repo";
export class Repository {
constructor(
private repoData: RepositoryData,
) {
}
public get name(): string {
return this.repoData.name;
}
public get address(): string | undefined {
return this.repoData.address;
}
public get path(): string | undefined {
return this.repoData.path;
}
public get local(): boolean {
return this.repoData.local;
}
public update(data: {name?: string, address?: string, path?: string, local?: boolean}) {
this.repoData = Object.assign(this.repoData, data);
}
}

@ -0,0 +1,122 @@
import {FilterExpression, FilterExpressionQuery, 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 getSubfilterAtIndex(index: number, subindex: number): FilterQuery | undefined {
if (index < this.filters.length) {
const filterEntry = this.filters[index]!;
if ("OrExpression" in filterEntry) {
return filterEntry.OrExpression[subindex];
}
}
return undefined;
}
public hasFilter(expression: FilterExpression): boolean {
return !!this.filters.find(f => deepEqual(f, expression));
}
public hasSubfilter(query: FilterQuery): boolean {
return !!this.filters.find(f => {
if ("OrExpression" in f) {
return !!f.OrExpression.find(q => deepEqual(q, query));
} else {
return deepEqual(f.Query, query);
}
});
}
public addFilterExpression(filter: FilterExpression) {
this.filters.push(filter);
this.processChangesToOrExpressions();
}
public addFilter(filter: FilterExpression, index: number) {
this.filters = [...this.filters.slice(
0,
index
), 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 };
delete entry["Query"];
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"] || f["OrExpression"]!.length === 0);
}
});
if (index >= 0) {
this.filters.splice(index, 1);
}
this.processChangesToOrExpressions();
}
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);
}
}
this.processChangesToOrExpressions();
}
private processChangesToOrExpressions() {
const filters_to_remove: FilterExpression[] = [];
for (const filter of this.filters) {
if ("OrExpression" in filter && !("Query" in filter)) {
if (filter.OrExpression && filter.OrExpression.length === 1) {
const query = filter.OrExpression[0];
let newFilter = filter as unknown as FilterExpressionQuery & { OrExpression: undefined };
delete newFilter["OrExpression"];
newFilter.Query = query;
} else if (!filter.OrExpression || filter.OrExpression.length === 0) {
filters_to_remove.push(filter);
}
}
}
filters_to_remove.forEach(f => this.removeFilter(f));
}
}

@ -0,0 +1,30 @@
import {TagData} from "../api-types/tags";
export class Tag {
private normalizedTag?: string = undefined;
constructor(
private tagData: TagData,
) {
}
public get id(): number {
return this.tagData.id;
}
public get name(): string {
return this.tagData.name;
}
public get namespace(): string | undefined {
return this.tagData.namespace;
}
public getNormalizedOutput(): string {
if (!this.normalizedTag) {
this.normalizedTag = this.namespace ? this.namespace + ":" + this.name : this.name;
}
return this.normalizedTag;
}
}

@ -0,0 +1,11 @@
export function mapOptional<I, O>(mapFn: (value: I) => O): (value: I | undefined) => O | undefined {
return (value: I | undefined) => value ? mapFn(value) : undefined;
}
export function mapMany<I, O>(mapFn: (value: I) => O): (value: I[]) => O[] {
return (value: I[]) => value.map(mapFn);
}
export function mapNew<T, V>(classType: new (value: V) => T): (value: V) => T {
return (value: V) => new classType(value);
}

@ -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);

@ -1,14 +1,14 @@
<div id="content">
<mat-tab-group #tabGroup (selectedTabChange)="this.onTabSelectionChange($event)" class="main-tab-group"
animationDuration="0">
<mat-tab [label]="this.selectedRepository? 'Repository' : 'Repositories'">
<mat-tab-group #tabGroup (selectedTabChange)="this.onTabSelectionChange($event)" animationDuration="0"
class="main-tab-group">
<mat-tab [label]="this.selectedRepository? 'RepositoryData' : 'Repositories'">
<app-repositories-tab></app-repositories-tab>
</mat-tab>
<mat-tab *ngFor="let tab of tabs">
<ng-template mat-tab-label>
<div class="tab-label-div" (click)="this.onMouseClickTabLabel(tab, $event)">
<div (click)="this.onMouseClickTabLabel(tab, $event)" class="tab-label-div">
{{tab.category}}
<button class="close-tab-button" mat-icon-button (click)="this.closeTab(tab)">
<button (click)="this.closeTab(tab)" class="close-tab-button" mat-icon-button>
<ng-icon name="mat-close"></ng-icon>
</button>
</div>
@ -21,13 +21,13 @@
<mat-tab *ngIf="this.newTab" label="New Tab">
<div class="new-tab-content">
Select the tab type
<button mat-flat-button (click)="this.addFilesTab()" color="primary">Files</button>
<button mat-flat-button (click)="this.addImportTab()" color="primary">Import</button>
<button (click)="this.addFilesTab()" color="primary" mat-flat-button>Files</button>
<button (click)="this.addImportTab()" color="primary" mat-flat-button>Import</button>
</div>
</mat-tab>
<mat-tab *ngIf="this.selectedRepository" disabled>
<ng-template mat-tab-label>
<button class="new-tab-button" mat-icon-button (click)="this.addTab()">
<button (click)="this.addTab()" class="new-tab-button" mat-icon-button>
<ng-icon name="mat-plus"></ng-icon>
</button>
</ng-template>

@ -34,10 +34,11 @@ mat-tab-group {
float: right;
position: absolute;
right: 0;
top: 4px;
top: 0;
height: 100%;
ng-icon {
font-size: 1.5em;
margin-top: calc(-50% + 0.2em);
margin-top: calc(-50%);
--ng-icon__size: 0.4em;
}
}

@ -1,5 +1,5 @@
import {Component, ViewChild} from "@angular/core";
import {Repository} from "../../models/Repository";
import {Repository} from "../../../api/models/Repository";
import {RepositoryService} from "../../services/repository/repository.service";
import {MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {TagService} from "../../services/tag/tag.service";
@ -60,7 +60,7 @@ export class CoreComponent {
this.addTab();
}
});
})
});
}
async loadRepoData() {

@ -46,7 +46,9 @@ import {
RepositoryModule
} from "../shared/repository/repository/repository.module";
import {MatToolbarModule} from "@angular/material/toolbar";
import { RepositoryDetailsViewComponent } from './repositories-tab/repository-details-view/repository-details-view.component';
import {
RepositoryDetailsViewComponent
} from "./repositories-tab/repository-details-view/repository-details-view.component";
@NgModule({

@ -3,7 +3,7 @@
<mat-tab label="Search">
<app-file-search [state]="this.state" [availableTags]="this.allTags" [contextTags]="this.tags"
(searchStartEvent)="this.searchStartEvent.emit($event)"
(searchEndEvent)="this.onDisplayedFilesChange(); this.searchEndEvent.emit($event);"></app-file-search>
(searchEndEvent)="this.onDisplayedFilesChange(); this.searchEndEvent.emit($event);" [tagsLoading]="this.tagsLoading"></app-file-search>
</mat-tab>
<mat-tab *ngIf="this.selectedFiles.length > 0" label="Edit Tags">
<app-tag-edit #fileedit [files]="this.selectedFiles"></app-tag-edit>

@ -8,9 +8,9 @@ import {
SimpleChanges,
ViewChild
} from "@angular/core";
import {Tag} from "../../../../models/Tag";
import {Tag} from "../../../../../api/models/Tag";
import {TagService} from "../../../../services/tag/tag.service";
import {File} from "../../../../models/File";
import {File} from "../../../../../api/models/File";
import {
FileSearchComponent
} from "../../../shared/sidebar/file-search/file-search.component";
@ -42,6 +42,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
public allTags: Tag[] = [];
public files: File[] = [];
public tagsOfSelection: Tag[] = [];
public tagsLoading = false;
constructor(private repoService: RepositoryService, private tagService: TagService) {
this.repoService.selectedRepository.subscribe(
@ -53,7 +54,7 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
this.state.files.subscribe(async (files) => {
this.files = files;
await this.onDisplayedFilesChange();
})
});
if (this.fileSearch) {
await this.fileSearch.searchForFiles();
}
@ -75,18 +76,22 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
}
async loadTagsForDisplayedFiles() {
this.tagsLoading = true;
this.tagsOfFiles = await this.tagService.getTagsForFiles(
this.files.map(f => f.hash));
this.files.map(f => f.cd));
this.showAllTagsFallback();
this.tagsLoading = false;
}
async showFileDetails(files: File[]) {
this.tagsLoading = true;
this.tagsOfSelection = await this.tagService.getTagsForFiles(
files.map(f => f.hash))
files.map(f => f.cd));
this.tagsOfSelection = this.tagsOfSelection.sort(
(a, b) => a.getNormalizedOutput()
.localeCompare(b.getNormalizedOutput()));
this.tags = this.tagsOfSelection;
this.tagsLoading = false;
}
private async refreshFileSelection() {

@ -1,12 +1,17 @@
<mat-drawer-container autosize appInputReceiver (keyDownEvent)="this.onKeydown($event)">
<mat-drawer-container (keyDownEvent)="this.onKeydown($event)" appInputReceiver autosize>
<mat-drawer disableClose mode="side" opened>
<app-files-tab-sidebar [state]="this.state" (searchEndEvent)="this.contentLoading = false;"
(searchStartEvent)="this.contentLoading = true;"
[selectedFiles]="this.selectedFiles"></app-files-tab-sidebar>
<app-files-tab-sidebar (searchEndEvent)="this.contentLoading = false;" (searchStartEvent)="this.contentLoading = true;"
[selectedFiles]="this.selectedFiles"
[state]="this.state"></app-files-tab-sidebar>
</mat-drawer>
<mat-drawer-content>
<app-busy-indicator [blurBackground]="true" [busy]="contentLoading">
<app-file-multiview [mode]="state.mode.value" (modeChangeEvent)="state.mode.next($event)" (fileSelectEvent)="this.onFileSelect($event)" [files]="this.files" [preselectedFile]="this.getStateSelectedFile()"></app-file-multiview>
<app-busy-indicator [blurBackground]="true" [busy]="contentLoading" [darkenBackground]="true">
<app-file-multiview (fileSelectEvent)="this.onFileSelect($event)"
(modeChangeEvent)="state.mode.next($event)"
[files]="this.files"
[mode]="state.mode.value"
[preselectedFile]="this.getStateSelectedFile()"
[tabState]="this.state"></app-file-multiview>
</app-busy-indicator>
</mat-drawer-content>
</mat-drawer-container>

@ -1,5 +1,5 @@
import {Component, Input, OnInit} from "@angular/core";
import {File} from "../../../models/File";
import {File} from "../../../../api/models/File";
import {TabState} from "../../../models/TabState";
@Component({
@ -26,17 +26,17 @@ export class FilesTabComponent implements OnInit {
async onFileSelect(files: File[]) {
this.selectedFiles = files;
if (files.length === 1) {
this.state.selectedFileHash.next(files[0].hash);
this.state.selectedCD.next(files[0].cd);
} else {
this.state.selectedFileHash.next(undefined);
this.state.selectedCD.next(undefined);
}
}
public getStateSelectedFile(): File | undefined {
const hash = this.state.selectedFileHash.value;
const hash = this.state.selectedCD.value;
if (hash) {
return this.files.find(f => f.hash === hash);
return this.files.find(f => f.cd === hash);
} else {
return undefined;
}
@ -45,7 +45,7 @@ export class FilesTabComponent implements OnInit {
public async onKeydown(event: KeyboardEvent) {
switch (event.key) {
case "F5":
await this.state.findFiles()
await this.state.findFiles();
break;
}
}

@ -1,5 +1,5 @@
import {Component, EventEmitter, Input, Output} from "@angular/core";
import {File} from "../../../../models/File";
import {File} from "../../../../../api/models/File";
@Component({
selector: "app-import-tab-sidebar",

@ -1,5 +1,5 @@
import {Component, Input, OnInit} from "@angular/core";
import {File} from "../../../models/File";
import {File} from "../../../../api/models/File";
import {TabState} from "../../../models/TabState";
@Component({
@ -47,17 +47,17 @@ export class ImportTabComponent implements OnInit {
public onFileSelect(files: File[]) {
this.selectedFiles = files;
if (files.length === 1) {
this.state.selectedFileHash.next(files[0].hash);
this.state.selectedCD.next(files[0].cd);
} else {
this.state.selectedFileHash.next(undefined);
this.state.selectedCD.next(undefined);
}
}
public getSelectedFileFromState(): File | undefined {
const selectedHash = this.state.selectedFileHash.value;
const selectedHash = this.state.selectedCD.value;
if (selectedHash && this.files) {
return this.files.find(f => f.hash === selectedHash);
return this.files.find(f => f.cd === selectedHash);
} else {
return undefined;
}

@ -20,6 +20,6 @@ export class DownloadDaemonDialogComponent {
}
closeDialog(result: boolean) {
this.dialogRef.close(result)
this.dialogRef.close(result);
}
}

@ -4,7 +4,7 @@
</div>
<div class="repository-list">
<div *ngFor="let repository of repositories" class="repository-container">
<app-repository-card [repository]="repository"></app-repository-card>
<app-repository-card [repository]="repository" (openEvent)="this.onOpenRepository($event)"></app-repository-card>
</div>
</div>
</div>

@ -1,15 +1,18 @@
import {AfterViewInit, Component, OnInit} from "@angular/core";
import {Repository} from "../../../models/Repository";
import {
RepositoryService
} from "../../../services/repository/repository.service";
import {MatDialog} from "@angular/material/dialog";
import {
DownloadDaemonDialogComponent
} from "./download-daemon-dialog/download-daemon-dialog.component";
import {Repository} from "../../../../api/models/Repository";
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 {
AddRepositoryDialogComponent
} from "../../shared/repository/repository/add-repository-dialog/add-repository-dialog.component";
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 {JobService} from "../../../services/job/job.service";
import {StateService} from "../../../services/state/state.service";
type BusyDialogContext = { message: BehaviorSubject<string>, dialog: MatDialogRef<BusyDialogComponent> };
@Component({
selector: "app-repositories-tab",
@ -21,7 +24,10 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
public selectedRepository?: Repository;
constructor(
private errorBroker: ErrorBrokerService,
private repoService: RepositoryService,
private jobService: JobService,
private stateService: StateService,
public dialog: MatDialog
) {
}
@ -32,13 +38,52 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
this.repositories = repos;
}
});
this.repoService.selectedRepository.subscribe(repo => this.selectedRepository = repo);
this.repoService.selectedRepository.subscribe(
repo => this.selectedRepository = repo);
}
public async ngAfterViewInit() {
await this.checkAndPromptDaemonExecutable();
}
public async startDaemonAndSelectRepository(repository: Repository) {
try {
let dialogContext = this.openStartupDialog(repository);
let daemonRunning = await this.repoService.checkDaemonRunning(
repository.path!);
if (!daemonRunning) {
dialogContext.message.next("Starting repository daemon...");
await this.repoService.startDaemon(repository.path!);
await new Promise((res, _) => {
setTimeout(res, 2000); // wait for the daemon to start
});
}
await this.selectRepository(repository, dialogContext);
} catch (err) {
this.errorBroker.showError(err);
}
}
public async selectRepository(repository: Repository, dialogContext?: BusyDialogContext) {
dialogContext = dialogContext ?? this.openStartupDialog(repository);
try {
dialogContext.message.next("Opening repository...");
await this.repoService.setRepository(repository);
await this.runRepositoryStartupTasks(dialogContext);
dialogContext.message.next("Restoring previous tabs...");
await this.repoService.loadRepositories();
await this.stateService.loadState();
dialogContext.dialog.close(true);
} catch (err: any) {
this.errorBroker.showError(err);
dialogContext.message.next(
"Failed to open repository: " + err.toString());
await this.forceCloseRepository();
setTimeout(() => dialogContext!.dialog.close(true), 1000);
}
}
public openAddRepositoryDialog() {
this.dialog.open(AddRepositoryDialogComponent, {
disableClose: true,
@ -47,14 +92,69 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
});
}
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();
} catch {
}
try {
await this.repoService.disconnectSelectedRepository();
} catch {
}
}
private async runRepositoryStartupTasks(dialogContext: BusyDialogContext): Promise<void> {
dialogContext.message.next(
"Migrating content descriptors to new format...");
await this.jobService.runJob("MigrateContentDescriptors");
dialogContext.message.next("Calculating repository sizes...");
await this.jobService.runJob("CalculateSizes");
dialogContext.message.next("Checking integrity...");
await this.jobService.runJob("CheckIntegrity");
dialogContext.message.next("Finished repository startup");
}
private openStartupDialog(repository: Repository): BusyDialogContext {
const dialogMessage = new BehaviorSubject<string>(
"Opening repository...");
let dialog = this.dialog.open(BusyDialogComponent, {
data: {
title: `Opening repository ${repository.name}`,
message: dialogMessage,
allowCancel: true,
}, disableClose: true,
minWidth: "30%",
minHeight: "30%",
});
dialog.afterClosed().subscribe(async (result) => {
if (!result) {
await this.forceCloseRepository();
}
});
return { message: dialogMessage, dialog };
}
private async checkAndPromptDaemonExecutable() {
if (!await this.repoService.checkDameonConfigured()) {
const result = await this.dialog.open(DownloadDaemonDialogComponent, {
disableClose: true,
}).afterClosed().toPromise();
const result = await this.dialog.open(
DownloadDaemonDialogComponent,
{
disableClose: true,
}
).afterClosed().toPromise();
if (result) {
// recursion avoidance
setTimeout(async () => await this.checkAndPromptDaemonExecutable(), 0);
setTimeout(
async () => await this.checkAndPromptDaemonExecutable(), 0);
}
}
}

@ -9,11 +9,11 @@
<p *ngIf="!repository.local" class="repository-address">{{repository.address}}</p>
</mat-card-content>
<mat-action-list>
<button (click)="startDaemonAndSelectRepository()" *ngIf="!this.isSelectedRepository() && repository.local"
<button (click)="this.openEvent.next(repository)" *ngIf="!this.isSelectedRepository() && repository.local"
color="primary"
mat-flat-button>Open
</button>
<button (click)="selectRepository()" *ngIf="!this.isSelectedRepository() && !repository.local"
<button (click)="this.openEvent.next(repository)" *ngIf="!this.isSelectedRepository() && !repository.local"
[disabled]="!this.daemonRunning"
color="primary" mat-flat-button>Connect
</button>

@ -1,11 +1,15 @@
import {Component, Input, OnDestroy, OnInit, ViewChild} from "@angular/core";
import {Repository} from "../../../../models/Repository";
import {
Component, EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from "@angular/core";
import {Repository} from "../../../../../api/models/Repository";
import {
RepositoryService
} from "../../../../services/repository/repository.service";
import {
ErrorBrokerService
} from "../../../../services/error-broker/error-broker.service";
import {MatDialog} from "@angular/material/dialog";
import {
ConfirmDialogComponent
@ -25,6 +29,8 @@ import {
export class RepositoryCardComponent implements OnInit, OnDestroy {
@Input() repository!: Repository;
@Output() openEvent = new EventEmitter<Repository>();
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
public daemonRunning: boolean = false;
@ -33,7 +39,6 @@ export class RepositoryCardComponent implements OnInit, OnDestroy {
constructor(
public repoService: RepositoryService,
private errorBroker: ErrorBrokerService,
public dialog: MatDialog) {
}
@ -52,7 +57,7 @@ export class RepositoryCardComponent implements OnInit, OnDestroy {
}
public isSelectedRepository(): boolean {
return this.repoService.selectedRepository.getValue()?.name === this.repository.name
return this.repoService.selectedRepository.getValue()?.name === this.repository.name;
}
public async removeRepository() {
@ -118,31 +123,6 @@ export class RepositoryCardComponent implements OnInit, OnDestroy {
}
}
public async startDaemonAndSelectRepository() {
try {
if (!this.daemonRunning) {
await this.repoService.startDaemon(this.repository.path!);
this.daemonRunning = true;
await new Promise((res, _) => {
setTimeout(res, 2000) // wait for the daemon to start
});
}
await this.selectRepository();
} catch (err) {
this.errorBroker.showError(err);
}
}
public async selectRepository() {
this.busyIndicator.setBusy(true);
try {
await this.repoService.setRepository(this.repository);
} catch (err) {
this.errorBroker.showError(err);
}
this.busyIndicator.setBusy(false);
}
async checkRemoteRepositoryStatus() {
this.daemonRunning = await this.repoService.checkDaemonRunning(
this.repository.address!);
@ -156,6 +136,6 @@ export class RepositoryCardComponent implements OnInit, OnDestroy {
data: {
repository: this.repository
}
})
});
}
}

@ -1,25 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {ComponentFixture, TestBed} from "@angular/core/testing";
import { RepositoryDetailsViewComponent } from './repository-details-view.component';
import {
RepositoryDetailsViewComponent
} from "./repository-details-view.component";
describe('RepositoryDetailsViewComponent', () => {
let component: RepositoryDetailsViewComponent;
let fixture: ComponentFixture<RepositoryDetailsViewComponent>;
describe("RepositoryDetailsViewComponent", () => {
let component: RepositoryDetailsViewComponent;
let fixture: ComponentFixture<RepositoryDetailsViewComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RepositoryDetailsViewComponent ]
})
.compileComponents();
});
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RepositoryDetailsViewComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(RepositoryDetailsViewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
beforeEach(() => {
fixture = TestBed.createComponent(RepositoryDetailsViewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -6,13 +6,16 @@ import {
OnInit,
SimpleChanges
} from "@angular/core";
import {Repository} from "../../../../models/Repository";
import {Repository} from "../../../../../api/models/Repository";
import {
RepositoryService
} from "../../../../services/repository/repository.service";
import {RepositoryMetadata} from "../../../../models/RepositoryMetadata";
import {BehaviorSubject} from "rxjs";
import {SizeType} from "../../../../models/SizeMetadata";
import {MatDialog} from "@angular/material/dialog";
import {
BusyDialogComponent
} from "../../../shared/app-common/busy-dialog/busy-dialog.component";
@Component({
selector: "app-repository-details-view",
@ -30,7 +33,7 @@ export class RepositoryDetailsViewComponent implements OnInit, OnChanges, OnDest
public thumbFolderSize = new BehaviorSubject<string | undefined>(undefined);
public databaseFileSize = new BehaviorSubject<string | undefined>(undefined);
constructor(private repoService: RepositoryService) {
constructor(private repoService: RepositoryService, public dialog: MatDialog) {
}
public async ngOnInit() {
@ -49,21 +52,28 @@ export class RepositoryDetailsViewComponent implements OnInit, OnChanges, OnDest
}
public async closeRepository() {
let closeDialog = this.dialog.open(BusyDialogComponent, {
data: {
title: "Closing repository",
message: new BehaviorSubject("Closing repository...")
}
});
if (this.repository?.local) {
await this.repoService.closeSelectedRepository();
} else {
await this.repoService.disconnectSelectedRepository();
}
closeDialog.close(true);
}
public async getSizes() {
const totalSize = await this.repoService.getSize(SizeType.Total)
const totalSize = await this.repoService.getSize("Total");
this.totalSize.next(this.formatByteSize(totalSize.size));
const fileSize = await this.repoService.getSize(SizeType.FileFolder);
const fileSize = await this.repoService.getSize("FileFolder");
this.fileFolderSize.next(this.formatByteSize(fileSize.size));
const thumbSize = await this.repoService.getSize(SizeType.ThumbFolder);
const thumbSize = await this.repoService.getSize("ThumbFolder");
this.thumbFolderSize.next(this.formatByteSize(thumbSize.size));
const databaseSize = await this.repoService.getSize(SizeType.DatabaseFile);
const databaseSize = await this.repoService.getSize("DatabaseFile");
this.databaseFileSize.next(this.formatByteSize(databaseSize.size));
}
@ -82,7 +92,7 @@ export class RepositoryDetailsViewComponent implements OnInit, OnChanges, OnDest
} else if (size >= kib) {
return (size / kib).toFixed(2) + " KiB";
} else {
return size + " B"
return size + " B";
}
}

@ -0,0 +1,18 @@
import {NgModule} from "@angular/core";
import {CommonModule} from "@angular/common";
import {FileActionBaseComponent} from "./file-action-base/file-action-base.component";
@NgModule({
declarations: [
FileActionBaseComponent,
],
exports: [
FileActionBaseComponent,
],
imports: [
CommonModule
]
})
export class AppBaseModule {
}

@ -0,0 +1,179 @@
import {Component} from "@angular/core";
import {FileService} from "../../../../services/file/file.service";
import {clipboard} from "@tauri-apps/api";
import {FileHelper} from "../../../../services/file/file.helper";
import {FileStatus} from "../../../../../api/api-types/files";
import {File} from "../../../../../api/models/File";
import {SafeResourceUrl} from "@angular/platform-browser";
import {BehaviorSubject} from "rxjs";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {ConfirmDialogComponent, ConfirmDialogData} from "../../app-common/confirm-dialog/confirm-dialog.component";
import {MatDialog, MatDialogConfig, MatDialogRef} from "@angular/material/dialog";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
progress: BehaviorSubject<number>,
message: BehaviorSubject<string>,
};
@Component({
selector: "app-file-action-base",
template: "<h1>Do not use</h1>",
})
export class FileActionBaseComponent {
constructor(private dialog: MatDialog, private errorBroker: ErrorBrokerService, private fileService: FileService) {
}
public async copyFileContentDescriptor(file: File): Promise<void> {
await clipboard.writeText(file.cd);
}
public async exportFile(file: File): Promise<void> {
const path = await FileHelper.getFileDownloadLocation(file);
if (path) {
await this.errorBroker.try(() => this.fileService.saveFile(file, path));
}
}
public async updateStatus(files: File[], status: FileStatus) {
if (files.length === 1) {
let changeConfirmed;
if (status === "Deleted") {
changeConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to move this file to trash?",
"Delete",
"warn",
this.getImageThumbnail(files[0])
);
} else {
changeConfirmed = true;
}
if (changeConfirmed) {
await this.errorBroker.try(async () => {
const newFile = await this.fileService.updateFileStatus(files[0].id, status);
files[0].status = newFile.status;
});
}
} else {
const statusChangeConfirmed = await this.openConfirmDialog(
"Confirm mass status change",
`Do you really want to change the status of ${files.length} files to '${status}'?`,
"Change status",
status === "Deleted" ? "warn" : "primary"
);
if (statusChangeConfirmed) {
await this.iterateWithProgress(
`Updating file status to '${status}'`,
files,
(file) => this.errorBroker.try(async () => {
const newFile = await this.fileService.updateFileStatus(file.id, status);
file.status = newFile.status;
})
);
}
}
}
public async deletePermanently(files: File[]): Promise<boolean> {
if (files.length === 1) {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm deletion",
"Do you really want to permanently delete this file?",
"Delete permanently",
"warn",
this.getImageThumbnail(files[0]),
);
if (deletionConfirmed) {
await this.errorBroker.try(() => this.fileService.deleteFile(files[0].id));
return true;
}
} else {
const deletionConfirmed = await this.openConfirmDialog(
"Confirm mass deletion",
`Do you really want to permanently delete ${files.length} files?`,
"Delete permanently",
"warn"
);
if (deletionConfirmed) {
await this.iterateWithProgress(
"Deleting files",
files,
(file) => this.errorBroker.try(() => this.fileService.deleteFile(file.id))
);
return true;
}
}
return false;
}
protected getImageThumbnail(file: File): SafeResourceUrl | undefined {
const mimeParts = FileHelper.parseMime(file.mimeType);
if (mimeParts && ["image", "video"].includes(mimeParts[0])) {
return this.fileService.buildThumbnailUrl(file, 250, 250);
} else {
return;
}
}
protected async iterateWithProgress<T>(title: string, items: T[], action: (arg: T) => Promise<any>): Promise<void> {
const totalCount = items.length;
const dialogCtx = this.openProgressDialog(title, `0/${totalCount}`);
let count = 0;
for (const item of items) {
await action(item);
dialogCtx.message.next(`${++count}/${totalCount}`);
dialogCtx.progress.next(count / totalCount);
}
dialogCtx.dialog.close(true);
}
protected openProgressDialog(title: string, message: string): ProgressDialogContext {
const dialogMessage = new BehaviorSubject(message);
const dialogProgress = new BehaviorSubject(0);
const dialog = this.dialog.open(BusyDialogComponent, {
data: {
message: dialogMessage,
progress: dialogProgress,
title,
allowCancel: false,
},
disableClose: true,
minWidth: "30%",
minHeight: "30%",
});
return {
dialog,
message: dialogMessage,
progress: dialogProgress,
};
}
protected openConfirmDialog(
title: string,
question: string,
confirmAction: string,
confirmColor?: "primary" | "warn",
image?: SafeResourceUrl | string
): Promise<boolean> {
const dialog = this.dialog.open(ConfirmDialogComponent, {
data: {
title,
message: question,
confirmAction,
denyAction: "Cancel",
confirmColor,
image
}
} as MatDialogConfig & { data: ConfirmDialogData });
return dialog.afterClosed().toPromise();
}
}

@ -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,13 +8,12 @@ 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 {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 {SelectableComponent} from "./selectable/selectable.component";
import {MatProgressBarModule} from "@angular/material/progress-bar";
@NgModule({
@ -29,6 +24,8 @@ import {
ContentAwareImageComponent,
InputReceiverDirective,
MetadataEntryComponent,
BusyDialogComponent,
SelectableComponent,
],
exports: [
ConfirmDialogComponent,
@ -37,6 +34,7 @@ import {
ContentAwareImageComponent,
InputReceiverDirective,
MetadataEntryComponent,
SelectableComponent,
],
imports: [
CommonModule,
@ -44,7 +42,8 @@ import {
MatProgressSpinnerModule,
MatButtonModule,
MatDialogModule,
MatMenuModule
MatMenuModule,
MatProgressBarModule
]
})
export class AppCommonModule {

@ -0,0 +1,12 @@
<h1 class="title" mat-dialog-title>
{{title}}
</h1>
<div class="content" mat-dialog-content>
<mat-progress-bar [mode]="this.mode" [value]="this.progress" color="primary"></mat-progress-bar>
{{message}}
</div>
<div *ngIf="this.allowCancel" class="busy-dialog-actions" mat-dialog-actions>
<button (click)="this.dialogRef.close(false)" mat-flat-button>
Cancel
</button>
</div>

@ -0,0 +1,17 @@
mat-progress-spinner {
margin: auto;
padding-bottom: 1em;
}
.title, .content {
text-align: center;
}
.busy-dialog-actions {
display: block;
button {
float: right;
margin-left: 1em;
}
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {BusyDialogComponent} from "./busy-dialog.component";
describe("BusyDialogComponent", () => {
let component: BusyDialogComponent;
let fixture: ComponentFixture<BusyDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [BusyDialogComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BusyDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,37 @@
import {Component, Inject} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {BehaviorSubject} from "rxjs";
import {ProgressBarMode} from "@angular/material/progress-bar";
export type BusyDialogData = {
title: string,
message?: BehaviorSubject<string>,
progress?: BehaviorSubject<number>,
allowCancel?: boolean,
}
@Component({
selector: "app-busy-dialog",
templateUrl: "./busy-dialog.component.html",
styleUrls: ["./busy-dialog.component.scss"]
})
export class BusyDialogComponent {
public title: string;
public message?: string;
public allowCancel: boolean;
public progress = 0;
public mode: ProgressBarMode = "indeterminate";
constructor(public dialogRef: MatDialogRef<BusyDialogComponent>, @Inject(MAT_DIALOG_DATA) data: BusyDialogData) {
this.title = data.title;
if (data.message) {
data.message.subscribe(m => this.message = m);
}
if (data.progress) {
data.progress.subscribe(p => this.progress = p);
this.mode = "determinate";
}
this.allowCancel = data.allowCancel ?? false;
}
}

@ -15,12 +15,11 @@
}
.busy-indicator-overlay.blur {
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.busy-indicator-overlay.darken {
background-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.5);
}
::ng-deep app-busy-indicator {

@ -22,7 +22,7 @@ export class BusyIndicatorComponent {
}
public wrapOperation<T>(operation: Function): T | undefined {
this.setBusy(true)
this.setBusy(true);
try {
const result = operation();
this.setBusy(false);
@ -35,7 +35,7 @@ export class BusyIndicatorComponent {
}
public async wrapAsyncOperation<T>(operation: Function): Promise<T | undefined> {
this.setBusy(true)
this.setBusy(true);
try {
const result = await operation();
this.setBusy(false);

@ -2,6 +2,7 @@
{{title}}
</h1>
<div mat-dialog-content>
<app-content-aware-image *ngIf="this.image" [imageSrc]="this.image"></app-content-aware-image>
{{message}}
</div>
<div class="confirm-dialog-actions" mat-dialog-actions>

@ -6,3 +6,7 @@
margin-left: 1em;
}
}
app-content-aware-image {
margin-bottom: 1em;
}

@ -1,6 +1,17 @@
import {Component, Inject} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ThemePalette} from "@angular/material/core";
import {SafeResourceUrl} from "@angular/platform-browser";
export type ConfirmDialogData = {
title: string,
message: string,
image?: string | SafeResourceUrl,
confirmAction: string,
denyAction?: string,
confirmColor?: ThemePalette,
denyColor?: ThemePalette
};
@Component({
selector: "app-confirm-dialog",
@ -9,17 +20,18 @@ import {ThemePalette} from "@angular/material/core";
})
export class ConfirmDialogComponent {
title = "";
message = "";
confirmAction = "";
confirmColor: ThemePalette = "primary";
denyAction = "Cancel";
denyColor: ThemePalette = "accent";
public title = "";
public message = "";
public confirmAction = "";
public image?: string | SafeResourceUrl;
public confirmColor: ThemePalette = "primary";
public denyAction = "Cancel";
public denyColor: ThemePalette = "accent";
constructor(
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
@Inject(
MAT_DIALOG_DATA) data: { title: string, message: string, confirmAction: string, denyAction?: string, confirmColor?: ThemePalette, denyColor?: ThemePalette}
MAT_DIALOG_DATA) data: ConfirmDialogData
) {
this.title = data.title;
this.message = data.message;
@ -27,6 +39,7 @@ export class ConfirmDialogComponent {
this.denyAction = data.denyAction ?? this.denyAction;
this.confirmColor = data.confirmColor ?? this.confirmColor;
this.denyColor = data.denyColor ?? this.denyColor;
this.image = data.image;
}
public closeDialog(result: boolean) {

@ -11,7 +11,7 @@ export class ContextMenuComponent {
public x: string = "0";
public y: string = "0";
@ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger
@ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger;
constructor() {
}

@ -1,8 +1,8 @@
import { InputReceiverDirective } from './input-receiver.directive';
import {InputReceiverDirective} from "./input-receiver.directive";
describe('InputReceiverDirective', () => {
it('should create an instance', () => {
const directive = new InputReceiverDirective();
expect(directive).toBeTruthy();
});
describe("InputReceiverDirective", () => {
it("should create an instance", () => {
const directive = new InputReceiverDirective();
expect(directive).toBeTruthy();
});
});

@ -0,0 +1,3 @@
<div (click)="this.onClick()" [class.selected]="this.selected" class="selectable">
<ng-content></ng-content>
</div>

@ -0,0 +1,7 @@
.selectable.selected {
background-color: #5c5c5c;
}
body {
cursor: pointer;
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {SelectableComponent} from "./selectable.component";
describe("SelectableComponent", () => {
let component: SelectableComponent;
let fixture: ComponentFixture<SelectableComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SelectableComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SelectableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,25 @@
import {Component, EventEmitter, Output} from "@angular/core";
@Component({
selector: "app-selectable",
templateUrl: "./selectable.component.html",
styleUrls: ["./selectable.component.scss"]
})
export class SelectableComponent {
public selected = false;
@Output() appSelect = new EventEmitter<this>();
@Output() appUnselect = new EventEmitter<this>();
constructor() {
}
public onClick(): void {
this.selected = !this.selected;
if (this.selected) {
this.appSelect.emit(this);
} else {
this.appUnselect.emit(this);
}
}
}

@ -3,7 +3,7 @@
<app-audio-viewer *ngIf="getContentType() === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer>
<div *ngIf="getContentType() === 'other'" class="download-prompt">
<span>Unsupported content type <b>{{this.file.mime_type}}</b></span>
<span>Unsupported content type <b>{{this.file.mimeType}}</b></span>
<button (click)="this.downloadContent()" color="primary" mat-flat-button>Download</button>
</div>
<app-busy-indicator></app-busy-indicator>

@ -8,7 +8,7 @@ import {
ViewChild
} from "@angular/core";
import {SafeResourceUrl} from "@angular/platform-browser";
import {File} from "../../../../models/File";
import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {
@ -64,10 +64,7 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
}
public getContentType(): ContentType {
if (!this.file.mime_type) {
return "other";
}
let mimeParts = this.file.mime_type.split("/");
let mimeParts = this.file.mimeType.split("/");
const type = mimeParts.shift() ?? "other";
const subtype = mimeParts.shift() ?? "*";
@ -84,7 +81,7 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
}
public async downloadContent() {
const path = await FileHelper.getFileDownloadLocation(this.file)
const path = await FileHelper.getFileDownloadLocation(this.file);
if (path) {
try {

@ -54,12 +54,12 @@ export class ImageViewerComponent implements OnChanges {
const delta = event.wheelDelta ?? event.detail;
if (delta > 0) {
this.imageZoom += 0.2
this.imageZoom += 0.2;
if (this.imageZoom > 4) {
this.imageZoom = 4;
}
} else if (delta < 0) {
this.imageZoom -= 0.2
this.imageZoom -= 0.2;
if (this.imageZoom < 0.5) {
this.imageZoom = 0.5;
}

@ -10,7 +10,7 @@ import {
SimpleChanges,
ViewChild
} from "@angular/core";
import {File} from "../../../../models/File";
import {File} from "../../../../../api/models/File";
import {Selectable} from "../../../../models/Selectable";
import {
SchedulingService
@ -62,7 +62,7 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
this.workId = this.schedulingService.addWork(LOADING_WORK_KEY,
async () => {
await this.schedulingService.delay(1);
this.loading = false
this.loading = false;
});
}
}

@ -1,6 +1,27 @@
<app-context-menu #contextMenu>
<ng-content select="[content-before]"></ng-content>
<button (click)="this.copyFileHash()" mat-menu-item>Copy Hash</button>
<button (click)="this.exportFile()" mat-menu-item>Save As...</button>
<ng-container *ngIf="this.files">
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionArchive" mat-menu-item>Archive
</button>
<button (click)="this.updateStatus(this.files, 'Imported')" *ngIf="actionImported" mat-menu-item>Back to
imported
</button>
<button (click)="this.updateStatus(this.files, 'Deleted')"
*ngIf="actionDelete"
mat-menu-item>Delete
</button>
<button (click)="this.deleteFilesPermanently()"
*ngIf="actionDeletePermantently"
mat-menu-item>Delete permanently
</button>
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<!-- everything that only applies to a single file -->
<ng-container>
<button (click)="this.copyFileContentDescriptor(this.files[0])" mat-menu-item>Copy Content Descriptor
</button>
<button (click)="this.exportFile(this.files[0])" mat-menu-item>Save As...</button>
</ng-container>
</ng-container>
<ng-content></ng-content>
</app-context-menu>

@ -1,47 +1,71 @@
import {Component, ViewChild} from "@angular/core";
import {File} from "../../../../models/File";
import {
ContextMenuComponent
} from "../../app-common/context-menu/context-menu.component";
import {clipboard} from "@tauri-apps/api";
import {Component, EventEmitter, OnChanges, Output, SimpleChanges, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {ContextMenuComponent} from "../../app-common/context-menu/context-menu.component";
import {FileService} from "../../../../services/file/file.service";
import {
ErrorBrokerService
} from "../../../../services/error-broker/error-broker.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {MatDialog, MatDialogRef} from "@angular/material/dialog";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {BehaviorSubject} from "rxjs";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
progress: BehaviorSubject<number>,
message: BehaviorSubject<string>,
};
@Component({
selector: "app-file-context-menu",
templateUrl: "./file-context-menu.component.html",
styleUrls: ["./file-context-menu.component.scss"]
})
export class FileContextMenuComponent {
export class FileContextMenuComponent extends FileActionBaseComponent implements OnChanges {
public files: File[] = [];
public file!: File;
public actionImported = false;
public actionArchive = false;
public actionRestore = false;
public actionDelete = false;
public actionDeletePermantently = false;
@ViewChild("contextMenu") contextMenu!: ContextMenuComponent;
@Output() fileDeleted = new EventEmitter<File[]>();
constructor(private fileService: FileService, private errorBroker: ErrorBrokerService) {
constructor(fileService: FileService, errorBroker: ErrorBrokerService, dialog: MatDialog) {
super(dialog, errorBroker, fileService);
}
public onContextMenu(event: MouseEvent, file: File) {
this.file = file;
public ngOnChanges(changes: SimpleChanges): void {
if (changes["files"]) {
this.applyStatus();
}
}
public onContextMenu(event: MouseEvent, files: File[]) {
this.files = files;
this.applyStatus();
this.contextMenu.onContextMenu(event);
}
public async copyFileHash(): Promise<void> {
await clipboard.writeText(this.file.hash);
public async deleteFilesPermanently() {
const deleted = await this.deletePermanently(this.files);
if (deleted) {
this.fileDeleted.emit(this.files);
}
}
public async exportFile(): Promise<void> {
const path = await FileHelper.getFileDownloadLocation(this.file)
private applyStatus() {
this.actionDeletePermantently = true;
this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false;
if (path) {
try {
await this.fileService.saveFile(this.file, path);
} catch (err) {
this.errorBroker.showError(err);
}
for (const file of this.files) {
this.actionDeletePermantently &&= file.status === "Deleted";
this.actionDelete ||= file.status !== "Deleted";
this.actionArchive ||= file.status !== "Archived" && file.status !== "Deleted";
this.actionImported ||= file.status !== "Imported" && file.status !== "Deleted";
this.actionRestore ||= file.status === "Deleted";
}
}
}

@ -1,13 +1,13 @@
<div class="gallery-container" #inner fxLayout="column" appInputReceiver (keyDownEvent)="handleKeydownEvent($event)">
<button (click)="this.closeEvent.emit(this)" class="close-button" mat-icon-button>
<div #inner (keyDownEvent)="handleKeydownEvent($event)" appInputReceiver class="gallery-container" fxLayout="column">
<button (click)="this.appClose.emit(this)" class="close-button" mat-icon-button>
<ng-icon name="mat-close"></ng-icon>
</button>
<div (dblclick)="this.selectedFile? this.fileDblClickEvent.emit(this.selectedFile.data) : null"
<div (dblclick)="this.selectedFile? this.fileDblClick.emit(this.selectedFile.data) : null"
class="file-full-view"
fxFlex="80%">
<app-content-viewer *ngIf="this.selectedFile"
(contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, this.selectedFile!.data)"
[file]="this.selectedFile!.data"></app-content-viewer>
<app-content-viewer (contextmenu)="this.selectedFile && fileContextMenu.onContextMenu($event, [this.selectedFile!.data])"
*ngIf="this.selectedFile"
[file]="this.selectedFile!.data"></app-content-viewer>
</div>
<mat-divider fxFlex></mat-divider>
<div class="file-scroll-view" fxFlex="20%">
@ -15,9 +15,9 @@
minBufferPx="1000" orientation="horizontal">
<div *cdkVirtualFor="let entry of entries" class="file-item">
<app-file-card (clickEvent)="onEntrySelect($event.entry)"
[entry]="entry"></app-file-card>
[entry]="entry"></app-file-card>
</div>
</cdk-virtual-scroll-viewport>
</div>
</div>
<app-file-context-menu #fileContextMenu></app-file-context-menu>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)"></app-file-context-menu>

@ -1,8 +1,8 @@
import {
AfterContentInit, AfterViewInit,
Component, ElementRef,
AfterViewInit,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnInit,
@ -10,12 +10,13 @@ import {
SimpleChanges,
ViewChild
} from "@angular/core";
import {File} from "../../../../../models/File";
import {File} from "../../../../../../api/models/File";
import {FileService} from "../../../../../services/file/file.service";
import {SafeResourceUrl} from "@angular/platform-browser";
import {Selectable} from "../../../../../models/Selectable";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service";
import {Key} from "w3c-keys";
@Component({
selector: "app-file-gallery",
@ -26,14 +27,17 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
@Input() files: File[] = [];
@Input() preselectedFile: File | undefined;
@Output() fileSelectEvent = new EventEmitter<File | undefined>();
@Output() fileDblClickEvent = new EventEmitter<File>();
@Output() closeEvent = new EventEmitter<FileGalleryComponent>();
entries: Selectable<File>[] = [];
@Output() fileSelect = new EventEmitter<File | undefined>();
@Output() fileDblClick = new EventEmitter<File>();
@Output() appClose = new EventEmitter<FileGalleryComponent>();
@Output() fileDelete = new EventEmitter<File>();
@Output() fileDeleted = new EventEmitter<File[]>();
@ViewChild("virtualScroll") virtualScroll!: CdkVirtualScrollViewport;
@ViewChild("inner") inner!: ElementRef<HTMLDivElement>;
public entries: Selectable<File>[] = [];
public selectedFile: Selectable<File> | undefined;
public fileContentUrl: SafeResourceUrl | undefined;
@ -48,7 +52,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
if (!this.selectedFile || this.files.indexOf(
this.selectedFile.data) < 0) {
await this.onEntrySelect(
this.getPreselectedEntry() ?? this.entries[0])
this.getPreselectedEntry() ?? this.entries[0]);
}
}
@ -59,15 +63,15 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
public async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (changes["files"]) {
this.entries = this.files.map(
f => new Selectable(f, f.hash == this.selectedFile?.data.hash));
f => new Selectable(f, f.id == this.selectedFile?.data.id));
const selectedIndex = this.files.findIndex(
f => f.hash === this.selectedFile?.data.hash);
f => f.id === this.selectedFile?.data.id);
if (!this.selectedFile || selectedIndex < 0) {
await this.onEntrySelect(
this.getPreselectedEntry() ?? this.entries[0])
this.getPreselectedEntry() ?? this.entries[0]);
} else {
await this.onEntrySelect(this.entries[selectedIndex])
await this.onEntrySelect(this.entries[selectedIndex]);
}
}
}
@ -86,11 +90,13 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
if (this.virtualScroll) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => this.scrollToSelection(),
0); // we need to make sure the viewport has rendered
this.scrollTimeout = setTimeout(
() => this.scrollToSelection(),
0
); // we need to make sure the viewport has rendered
}
this.fileSelectEvent.emit(this.selectedFile.data);
this.fileSelect.emit(this.selectedFile.data);
}
}
@ -101,7 +107,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
async loadSelectedFile() {
if (this.selectedFile) {
this.fileContentUrl = this.fileService.buildContentUrl(
this.selectedFile.data)
this.selectedFile.data);
}
}
@ -117,7 +123,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
}
await this.onEntrySelect(this.entries[index]);
} else {
await this.onEntrySelect(this.entries[0])
await this.onEntrySelect(this.entries[0]);
}
}
@ -133,7 +139,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
}
await this.onEntrySelect(this.entries[index]);
} else {
await this.onEntrySelect(this.entries[0])
await this.onEntrySelect(this.entries[0]);
}
}
@ -150,15 +156,20 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
public async handleKeydownEvent(event: KeyboardEvent) {
switch (event.key) {
case "ArrowRight":
case Key.ArrowRight:
await this.nextItem();
break;
case "ArrowLeft":
case Key.ArrowLeft:
await this.previousItem();
break;
case "Escape":
case Key.Escape:
this.onEscapeClick();
break;
case Key.Delete:
if (this.selectedFile) {
this.fileDelete.emit(this.selectedFile.data);
}
break;
}
}
@ -173,7 +184,8 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
if (selectedIndex > indexAdjustment) {
this.virtualScroll.scrollToOffset(
this.virtualScroll.measureScrollOffset("left") + 130,
"smooth");
"smooth"
);
}
}
}
@ -192,7 +204,7 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
private onEscapeClick(): void {
if (this.escapeCount === 1) {
this.closeEvent.emit(this);
this.appClose.emit(this);
} else {
this.escapeCount++;
setTimeout(() => this.escapeCount--, 500);

@ -1,12 +1,16 @@
<div class="file-gallery-inner" #inner appInputReceiver (keyDownEvent)="handleKeydownEvent($event)" (keyUpEvent)="handleKeyupEvent($event)">
<div #inner
(keyDownEvent)="handleKeydownEvent($event)"
(keyUpEvent)="handleKeyupEvent($event)"
appInputReceiver
class="file-gallery-inner">
<cdk-virtual-scroll-viewport #virtualScrollGrid class="file-scroll" itemSize="260" maxBufferPx="2000"
minBufferPx="500">
<div *cdkVirtualFor="let rowEntry of partitionedGridEntries">
<div class="file-row">
<app-file-card
(clickEvent)="setSelectedFile($event.entry)"
(contextmenu)="fileContextMenu.onContextMenu($event, gridEntry.data)"
(dblClickEvent)="fileOpenEvent.emit($event.entry.data)"
(contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())"
(dblClickEvent)="fileOpen.emit($event.entry.data)"
*ngFor="let gridEntry of rowEntry"
[entry]="gridEntry"></app-file-card>
</div>
@ -14,8 +18,12 @@
</cdk-virtual-scroll-viewport>
</div>
<app-file-context-menu #fileContextMenu>
<button (click)="this.fileOpenEvent.emit(fileContextMenu.file)" mat-menu-item content-before="">Open</button>
<button (click)="this.regenerateThumbnail(fileContextMenu.file)" mat-menu-item>Regenerate thumbnail</button>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)">
<button (click)="this.fileOpen.emit(fileContextMenu.files[0])"
*ngIf="fileContextMenu.files.length === 1"
content-before=""
mat-menu-item>Open
</button>
<button (click)="this.regenerateThumbnail(fileContextMenu.files)" mat-menu-item>Regenerate thumbnail</button>
</app-file-context-menu>

@ -1,9 +1,8 @@
import {
AfterContentInit, AfterViewInit,
AfterViewInit,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnInit,
@ -11,12 +10,13 @@ import {
SimpleChanges,
ViewChild
} from "@angular/core";
import {File} from "../../../../../models/File";
import {File} from "../../../../../../api/models/File";
import {FileCardComponent} from "../../file-card/file-card.component";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service";
import {FileService} from "../../../../../services/file/file.service";
import {Selectable} from "../../../../../models/Selectable";
import {Key} from "w3c-keys";
@Component({
selector: "app-file-grid",
@ -28,8 +28,10 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@Input() files: File[] = [];
@Input() columns: number = 6;
@Input() preselectedFile: File | undefined;
@Output() fileOpenEvent = new EventEmitter<File>();
@Output() fileSelectEvent = new EventEmitter<File[]>();
@Output() fileOpen = new EventEmitter<File>();
@Output() fileSelect = new EventEmitter<File[]>();
@Output() fileDelete = new EventEmitter<File[]>();
@Output() fileDeleted = new EventEmitter<File[]>();
@ViewChild("virtualScrollGrid") virtualScroll!: CdkVirtualScrollViewport;
@ViewChild("inner") inner!: ElementRef<HTMLDivElement>;
@ -38,7 +40,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
partitionedGridEntries: Selectable<File>[][] = [];
private shiftClicked = false;
private ctrlClicked = false;
private gridEntries: Selectable<File>[] = []
private gridEntries: Selectable<File>[] = [];
constructor(
private tabService: TabService,
@ -73,7 +75,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
setSelectedFile(clickedEntry: Selectable<File>) {
if (!(this.shiftClicked || this.ctrlClicked) && this.selectedEntries.length > 0) {
this.selectedEntries.forEach(entry => {
if (entry !== clickedEntry) entry.selected = false
if (entry !== clickedEntry) entry.selected = false;
});
this.selectedEntries = [];
}
@ -90,7 +92,13 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.selectedEntries.push(clickedEntry);
}
}
this.fileSelectEvent.emit(this.selectedEntries.map(g => g.data));
this.fileSelect.emit(this.selectedEntries.map(g => g.data));
}
public selectEntryWhenNotSelected(entry: Selectable<File>) {
if (!entry.selected) {
this.setSelectedFile(entry);
}
}
public adjustElementSizes(): void {
@ -99,8 +107,66 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
}
}
public async regenerateThumbnail(file: File) {
await this.fileService.deleteThumbnails(file);
public async regenerateThumbnail(files: File[]) {
for (const file of files) {
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 Key.ArrowRight:
this.handleArrowSelect("right");
break;
case Key.ArrowLeft:
this.handleArrowSelect("left");
break;
case Key.ArrowDown:
this.handleArrowSelect("down");
break;
case Key.ArrowUp:
this.handleArrowSelect("up");
break;
case Key.PageDown:
this.pageDown();
break;
case Key.PageUp:
this.pageUp();
break;
case Key.a:
case Key.A:
if (this.shiftClicked && this.ctrlClicked) {
this.selectNone();
} else if (this.ctrlClicked) {
event.preventDefault();
this.selectAll();
}
break;
case Key.Enter:
if (this.selectedEntries.length === 1) {
this.fileOpen.emit(this.selectedEntries[0].data);
}
break;
case Key.Delete:
this.fileDelete.emit(this.selectedEntries.map(e => e.data));
break;
}
}
public getSelectedFiles(): File[] {
return this.selectedEntries.map(e => e.data);
}
public handleKeyupEvent(event: KeyboardEvent) {
this.shiftClicked = event.shiftKey ? false : this.shiftClicked;
this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked;
}
private setPartitionedGridEntries() {
@ -110,8 +176,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(
@ -196,14 +264,14 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
selectedIndex += this.columns;
break;
case "left":
selectedIndex --;
selectedIndex--;
break;
case "right":
selectedIndex++
selectedIndex++;
break;
}
while (selectedIndex < 0) {
selectedIndex = this.gridEntries.length + selectedIndex
selectedIndex = this.gridEntries.length + selectedIndex;
}
if (selectedIndex > this.gridEntries.length) {
selectedIndex %= this.gridEntries.length;
@ -222,61 +290,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");

@ -1,7 +1,14 @@
<app-file-grid #fileGrid (fileOpenEvent)="this.onFileOpen($event)" (fileSelectEvent)="this.onFileSelect($event)"
<app-file-grid (fileDelete)="this.onFileDelete($event)"
(fileDeleted)="this.onFileDeleted($event)"
(fileOpen)="this.onFileOpen($event)"
(fileSelect)="this.onFileSelect($event)"
*ngIf="this.mode === 'grid'"
[files]="this.files" [preselectedFile]="this.preselectedFile"></app-file-grid>
<app-file-gallery #fileGallery (closeEvent)="this.setMode('grid')" (fileSelectEvent)="this.onSingleFileSelect($event)"
[files]="this.files"
[preselectedFile]="this.preselectedFile"></app-file-grid>
<app-file-gallery (appClose)="this.setMode('grid')"
(fileDelete)="this.onFileDelete([$event])"
(fileDeleted)="this.onFileDeleted($event)"
(fileSelect)="this.onSingleFileSelect($event)"
*ngIf="this.mode === 'gallery'"
[files]="this.files"
[preselectedFile]="this.preselectedFile"></app-file-gallery>

@ -1,29 +1,27 @@
import {
AfterViewChecked, AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild
} from "@angular/core";
import {File} from "../../../../models/File";
import {AfterViewInit, Component, EventEmitter, Input, Output, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {FileGalleryComponent} from "./file-gallery/file-gallery.component";
import {FileGridComponent} from "./file-grid/file-grid.component";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
import {MatDialog} from "@angular/material/dialog";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {FileService} from "../../../../services/file/file.service";
import {TabState} from "../../../../models/TabState";
@Component({
selector: "app-file-multiview",
templateUrl: "./file-multiview.component.html",
styleUrls: ["./file-multiview.component.scss"]
})
export class FileMultiviewComponent implements AfterViewInit {
export class FileMultiviewComponent extends FileActionBaseComponent implements AfterViewInit {
@Input() files!: File[];
@Input() mode: "grid" | "gallery" = "grid";
@Input() tabState!: TabState;
@Output() fileOpenEvent = new EventEmitter<File>();
@Output() fileSelectEvent = new EventEmitter<File[]>();
@Output() modeChangeEvent = new EventEmitter<"grid"|"gallery">();
@Output() modeChangeEvent = new EventEmitter<"grid" | "gallery">();
@ViewChild(FileGalleryComponent) fileGallery!: FileGalleryComponent;
@ViewChild(FileGridComponent) fileGrid!: FileGridComponent;
@ -31,12 +29,13 @@ export class FileMultiviewComponent implements AfterViewInit {
public selectedFiles: File[] = [];
@Input() public preselectedFile: File | undefined;
constructor() {
constructor(dialog: MatDialog, errorBroker: ErrorBrokerService, fileService: FileService) {
super(dialog, errorBroker, fileService);
}
public ngAfterViewInit(): void {
if (this.preselectedFile) {
this.fileSelectEvent.emit([this.preselectedFile])
this.fileSelectEvent.emit([this.preselectedFile]);
this.selectedFiles = [this.preselectedFile];
}
}
@ -59,7 +58,7 @@ export class FileMultiviewComponent implements AfterViewInit {
public onFileOpen(file: File): void {
this.preselectedFile = file;
this.setMode("gallery")
this.setMode("gallery");
this.fileOpenEvent.emit(file);
}
@ -67,4 +66,27 @@ export class FileMultiviewComponent implements AfterViewInit {
this.mode = mode;
this.modeChangeEvent.emit(mode);
}
public async onFileDelete(files: File[]): Promise<void> {
let deletePermanently = true;
for (const file of files) {
deletePermanently &&= file.status === "Deleted";
}
if (deletePermanently) {
const deleted = await this.deletePermanently(files);
if (deleted) {
this.onFileDeleted(files);
}
} else {
await this.updateStatus(files, "Deleted");
}
}
public onFileDeleted(deletedFiles: File[]): void {
this.files = this.files.filter(f => deletedFiles.findIndex(df => df.id === f.id) < 0);
this.tabState.files.next(this.files);
}
}

@ -2,7 +2,7 @@
borderRadius="0.25em"></app-content-aware-image>
<div *ngIf="this.getThumbnailSupported() && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="getFileType() === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="this.file.mime_type === 'image/gif'" class="gif-icon" name="mat-gif"></ng-icon>
<ng-icon *ngIf="this.file.mimeType === 'image/gif'" class="gif-icon" name="mat-gif"></ng-icon>
</div>
<div *ngIf="!this.getThumbnailSupported() || !this.thumbUrl" class="file-type-icon">
<ng-icon *ngIf="getFileType() === 'image'" name="mat-image"></ng-icon>
@ -10,3 +10,7 @@
<ng-icon *ngIf="getFileType() === 'audio'" name="mat-audiotrack"></ng-icon>
<ng-icon *ngIf="getFileType() === 'text'" name="mat-description"></ng-icon>
</div>
<div *ngIf="file.status !== 'Archived'" class="file-status-icon">
<ng-icon *ngIf="file.status === 'Deleted'" name="mat-auto-delete"></ng-icon>
<ng-icon *ngIf="file.status === 'Imported'" name="mat-fiber-new"></ng-icon>
</div>

@ -1,3 +1,5 @@
@import "src/colors";
app-content-aware-image {
height: 100%;
width: 100%;
@ -24,6 +26,23 @@ app-content-aware-image {
right: 0;
}
.file-status-icon {
position: absolute;
top: 0;
right: 0;
height: 18%;
width: 18%;
display: flex;
border-bottom-left-radius: 50%;
background-color: transparentize($background, .5);
ng-icon {
align-self: flex-end;
margin: 0 0 auto auto;
font-size: 2em;
}
}
ng-icon.gif-icon {
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);

@ -1,17 +1,8 @@
import {
AfterViewInit,
Component,
Input,
OnChanges,
SimpleChanges
} from "@angular/core";
import {File} from "../../../../models/File";
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";
import {SafeResourceUrl} from "@angular/platform-browser";
import {
SchedulingService
} from "../../../../services/scheduling/scheduling.service";
@Component({
selector: "app-file-thumbnail",
@ -24,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() {
@ -36,19 +27,20 @@ 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
);
}
}
public getThumbnailSupported(): boolean {
const mimeParts = FileHelper.parseMime(this.file.mime_type);
const mimeParts = FileHelper.parseMime(this.file.mimeType);
return !!mimeParts && this.supportedThumbnailTypes.includes(
mimeParts[0]);
}
public getFileType(): string {
const mimeParts = FileHelper.parseMime(this.file.mime_type);
const mimeParts = FileHelper.parseMime(this.file.mimeType);
return (mimeParts && mimeParts[0]) ?? "other";
}
}

@ -1,44 +1,28 @@
import {NgModule} from "@angular/core";
import {CommonModule} from "@angular/common";
import {
FileMultiviewComponent
} from "./file-multiview/file-multiview.component";
import {
FileGridComponent
} from "./file-multiview/file-grid/file-grid.component";
import {
FileGalleryComponent
} from "./file-multiview/file-gallery/file-gallery.component";
import {FileMultiviewComponent} from "./file-multiview/file-multiview.component";
import {FileGridComponent} from "./file-multiview/file-grid/file-grid.component";
import {FileGalleryComponent} from "./file-multiview/file-gallery/file-gallery.component";
import {FileCardComponent} from "./file-card/file-card.component";
import {
FileContextMenuComponent
} from "./file-context-menu/file-context-menu.component";
import {
FileThumbnailComponent
} from "./file-thumbnail/file-thumbnail.component";
import {
ContentViewerComponent
} from "./content-viewer/content-viewer.component";
import {
AudioViewerComponent
} from "./content-viewer/audio-viewer/audio-viewer.component";
import {
ImageViewerComponent
} from "./content-viewer/image-viewer/image-viewer.component";
import {
VideoViewerComponent
} from "./content-viewer/video-viewer/video-viewer.component";
import {FileContextMenuComponent} from "./file-context-menu/file-context-menu.component";
import {FileThumbnailComponent} from "./file-thumbnail/file-thumbnail.component";
import {ContentViewerComponent} from "./content-viewer/content-viewer.component";
import {AudioViewerComponent} from "./content-viewer/audio-viewer/audio-viewer.component";
import {ImageViewerComponent} from "./content-viewer/image-viewer/image-viewer.component";
import {VideoViewerComponent} from "./content-viewer/video-viewer/video-viewer.component";
import {AppCommonModule} from "../app-common/app-common.module";
import {MatSliderModule} from "@angular/material/slider";
import {NgIconsModule} from "@ng-icons/core";
import {
MatAudiotrack,
MatAutoDelete,
MatClose,
MatDescription,
MatFiberNew,
MatGif,
MatImage,
MatMovie,
MatRefresh
MatRefresh,
} from "@ng-icons/material-icons";
import {DragDropModule} from "@angular/cdk/drag-drop";
import {MatButtonModule} from "@angular/material/button";
@ -77,7 +61,9 @@ import {MatCardModule} from "@angular/material/card";
MatMovie,
MatGif,
MatAudiotrack,
MatDescription
MatDescription,
MatAutoDelete,
MatFiberNew,
}),
DragDropModule,
MatButtonModule,
@ -89,4 +75,5 @@ import {MatCardModule} from "@angular/material/card";
MatCardModule
]
})
export class FileModule { }
export class FileModule {
}

@ -0,0 +1,18 @@
<mat-form-field appearance="fill">
<mat-label>
Enter a filter expression
</mat-label>
<input
(keydown.enter)="addExpressionByInput()"
[formControl]="formControl"
[matAutocomplete]="auto"
[value]="this.value"
matInput>
<ng-content></ng-content>
<mat-autocomplete #auto
(optionSelected)="this.skipEnterOnce = true">
<mat-option *ngFor="let filter of autosuggestFilters | async" [value]="filter.value">
{{filter.display}}
</mat-option>
</mat-autocomplete>
</mat-form-field>

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {FilterInputComponent} from "./filter-input.component";
describe("FilterInputComponent", () => {
let component: FilterInputComponent;
let fixture: ComponentFixture<FilterInputComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FilterInputComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FilterInputComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,191 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {Observable} from "rxjs";
import {FormControl} from "@angular/forms";
import {Tag} from "../../../../../api/models/Tag";
import {FilterExpression, FilterQuery} from "../../../../../api/api-types/files";
import {debounceTime, map, startWith} from "rxjs/operators";
import {compareSearchResults} from "../../../../utils/compare-utils";
import {FilterQueryBuilder} from "../../../../../api/models/FilterQueryBuilder";
type AutocompleteEntry = {
value: string,
display: string,
};
@Component({
selector: "app-filter-input",
templateUrl: "./filter-input.component.html",
styleUrls: ["./filter-input.component.scss"]
})
export class FilterInputComponent implements OnChanges {
@Input() value: string | undefined;
@Input() availableTags: Tag[] = [];
@Output() filterAdded = new EventEmitter<FilterExpression>();
public autosuggestFilters: Observable<AutocompleteEntry[]>;
public formControl = new FormControl();
public skipEnterOnce = false;
private propertyQueriesWithValues: { [key: string]: (string | undefined)[] } = {
".status": ["imported", "archived", "deleted"],
".fileSize": [undefined],
".importedTime": [undefined],
".createdTime": [undefined],
".changedTime": [undefined],
".contentDescriptor": [undefined],
".fileId": [undefined],
".tagCount": [undefined]
};
private comparators = [
">",
"<",
"="
];
private tagsForAutocomplete: string[] = [];
constructor() {
this.autosuggestFilters = this.formControl.valueChanges.pipe(
startWith(null),
debounceTime(250),
map((value) => value ? this.filterAutosuggestFilters(value) : this.tagsForAutocomplete.slice(
0,
20
).map(t => {
return { value: t, display: this.buildAutocompleteValue(t) };
}))
);
this.tagsForAutocomplete = this.availableTags.map(
t => t.getNormalizedOutput());
if (this.value) {
this.formControl.setValue(this.value);
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes["availableTags"]) {
this.tagsForAutocomplete = this.availableTags.map(
t => t.getNormalizedOutput());
}
if (changes["value"] && this.value) {
this.formControl.setValue(this.value);
}
}
public addExpressionByInput(): void {
if (this.skipEnterOnce) {
this.skipEnterOnce = false; // workaround to be able to listen to enter (because change is unrelieable) while still allowing enter in autocomplete
return;
}
const expressions = FilterQueryBuilder.buildFilterExpressionsFromString(this.formControl.value);
let valid: boolean;
if (expressions && "OrExpression" in expressions) {
valid = this.validateFilters(expressions.OrExpression);
} else if (expressions) {
valid = this.validateFilters([expressions.Query]);
} else {
valid = false;
}
if (valid) {
this.filterAdded.emit(expressions);
this.clearFilterInput();
} else {
this.formControl.setErrors(["invalid filters"]);
}
}
public buildAutocompleteValue(value: string): string {
if (this.formControl.value) {
const queryParts = this.formControl.value.split(/\s+or\s+/gi);
if (queryParts.length > 1) {
value = queryParts.slice(0, queryParts.length - 1).join(" OR ") + " OR " + value;
}
}
return value;
}
private validateFilters(filters: FilterQuery[]): boolean {
for (const filter of filters) {
if ("Tag" in filter && !this.tagsForAutocomplete.includes(filter["Tag"].tag)) {
console.debug("tags don't include", filter);
return false;
}
}
return true;
}
private filterAutosuggestFilters(filterValue: string): AutocompleteEntry[] {
const queryParts = filterValue.split(/\s+or\s+/gi);
const latestQuery = queryParts[queryParts.length - 1];
const trimmedValue = latestQuery.trim();
let isNegation = trimmedValue.startsWith("-");
const cleanValue = trimmedValue.replace(/^-/, "");
const autosuggestTags = this.tagsForAutocomplete.filter(t => t.includes(cleanValue)).map(t => isNegation ? "-" + t : t);
let propertyQuerySuggestions: string[] = [];
if (trimmedValue.startsWith(".")) {
propertyQuerySuggestions = this.buildPropertyQuerySuggestions(trimmedValue);
}
return [...autosuggestTags, ...propertyQuerySuggestions].sort((r, l) => compareSearchResults(
cleanValue,
r,
l
)).slice(0, 50).map(e => {
return {
display: e,
value: this.buildAutocompleteValue(e)
};
});
}
private clearFilterInput() {
this.formControl.setValue("");
}
private buildPropertyQuerySuggestions(trimmedValue: string): string[] {
const parts = trimmedValue.split(/ |==|=|<|>/g).filter(p => p.length > 0);
console.log(parts);
const validProperties = Object.keys(this.propertyQueriesWithValues).filter(q => q.toLowerCase().startsWith(parts[0].trim().toLowerCase()));
let validComparators = this.comparators.filter(c => trimmedValue.includes(c));
if (validComparators.length === 0) {
validComparators = this.comparators;
}
let value = "";
if (parts.length > 1 && !this.comparators.includes(parts[1].trim())) {
value = parts[1].trim();
} else if (parts.length > 2) {
value = parts[2].trim();
}
if (validComparators.length == 1) {
return validProperties.map(p => validComparators.filter(c => this.filterComparatorsForProperty(
c,
p
)).map(c => this.propertyQueriesWithValues[p].map(v => `${p} ${c} ${v ?? value}`.trim())).flat()).flat();
} else {
return validProperties.map(p => validComparators.filter((c) => this.filterComparatorsForProperty(c, p)).map(
c => `${p} ${c} ${value}`.trim())).flat();
}
}
private filterComparatorsForProperty(comparator: string, property: string): boolean {
console.log(comparator, property);
switch (property) {
case ".status":
case ".fileId":
case ".contentDescriptor":
return comparator === "=";
default:
return true;
}
}
}

@ -1,8 +1,6 @@
import {NgModule} from "@angular/core";
import {CommonModule} from "@angular/common";
import {
NativeFileSelectComponent
} from "./native-file-select/native-file-select.component";
import {NativeFileSelectComponent} from "./native-file-select/native-file-select.component";
import {TagInputComponent} from "./tag-input/tag-input.component";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatFormFieldModule} from "@angular/material/form-field";
@ -12,16 +10,19 @@ import {NgIconsModule} from "@ng-icons/core";
import {MatFolder, MatInsertDriveFile} from "@ng-icons/material-icons";
import {MatButtonModule} from "@angular/material/button";
import {FlexModule} from "@angular/flex-layout";
import {FilterInputComponent} from "./filter-input/filter-input.component";
@NgModule({
declarations: [
NativeFileSelectComponent,
TagInputComponent
TagInputComponent,
FilterInputComponent
],
exports: [
NativeFileSelectComponent,
TagInputComponent
TagInputComponent,
FilterInputComponent
],
imports: [
CommonModule,
@ -29,7 +30,7 @@ import {FlexModule} from "@angular/flex-layout";
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
NgIconsModule.withIcons({MatInsertDriveFile, MatFolder}),
NgIconsModule.withIcons({ MatInsertDriveFile, MatFolder }),
MatButtonModule,
FlexModule,
]

@ -1,18 +1,10 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
ViewChild
} from "@angular/core";
import {Tag} from "../../../../models/Tag";
import {Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild} from "@angular/core";
import {Tag} from "../../../../../api/models/Tag";
import {FormControl} from "@angular/forms";
import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {Observable} from "rxjs";
import {debounceTime, map, startWith} from "rxjs/operators";
import {compareSearchResults} from "../../../../utils/compare-utils";
@Component({
selector: "app-tag-input",
@ -39,7 +31,27 @@ export class TagInputComponent implements OnChanges {
startWith(null),
debounceTime(250),
map((tag: string | null) => tag ? this.filterSuggestionTag(
tag) : this.tagsForAutocomplete.slice(0, 20)));
tag) : this.tagsForAutocomplete.slice(0, 20))
);
}
/**
* Normalizes the tag by removing whitespaces
* @param {string} tag
* @returns {string}
* @private
*/
private static 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;
}
}
ngOnChanges(changes: SimpleChanges): void {
@ -60,7 +72,7 @@ export class TagInputComponent implements OnChanges {
}
private addTag(value: string) {
const tag = this.normalizeTag(value);
const tag = TagInputComponent.normalizeTag(value);
if (tag.length > 0 && (this.allowInvalid || this.checkTagValid(tag))) {
this.tagAdded.emit(tag);
this.formControl.setValue("");
@ -69,22 +81,26 @@ export class TagInputComponent implements OnChanges {
}
private filterSuggestionTag(tag: string) {
let normalizedTag = this.normalizeTag(tag);
let normalizedTag = TagInputComponent.normalizeTag(tag);
const negated = normalizedTag.startsWith("-") && this.allowNegation;
normalizedTag = this.allowNegation ? normalizedTag.replace(/^-/,
"") : normalizedTag;
normalizedTag = this.allowNegation ? normalizedTag.replace(
/^-/,
""
) : normalizedTag;
const containsWildcard = normalizedTag.endsWith("*");
normalizedTag = this.allowWildcards ? normalizedTag.replace(/\*\s*$/,
"") : normalizedTag;
normalizedTag = this.allowWildcards ? normalizedTag.replace(
/\*\s*$/,
""
) : normalizedTag;
const autocompleteTags = this.tagsForAutocomplete.filter(
t => t.includes(normalizedTag))
.map(t => negated ? "-" + t : t)
.sort((l, r) => this.compareSuggestionTags(normalizedTag, l, r))
.sort((l, r) => compareSearchResults(normalizedTag, l, r))
.slice(0, 50);
if (containsWildcard) {
autocompleteTags.unshift(this.normalizeTag(tag));
autocompleteTags.unshift(TagInputComponent.normalizeTag(tag));
}
return autocompleteTags;
@ -104,37 +120,4 @@ export class TagInputComponent implements OnChanges {
}
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)
}
}
}

@ -9,7 +9,7 @@ import {
ErrorBrokerService
} from "../../../../../services/error-broker/error-broker.service";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {Repository} from "../../../../../models/Repository";
import {Repository} from "../../../../../../api/models/Repository";
@Component({
selector: "app-edit-repository-dialog",
@ -62,10 +62,7 @@ export class EditRepositoryDialogComponent {
}
await this.repoService.addRepository(name, path, address,
repositoryType === "local");
this.selectedRepository.name = name;
this.selectedRepository.local = repositoryType === "local";
this.selectedRepository.path = path;
this.selectedRepository.address = address;
this.selectedRepository.update({name, local: repositoryType === "local", path, address});
this.dialogRef.close();
} catch (err) {

@ -6,7 +6,7 @@ import {
ValidationErrors,
Validators
} from "@angular/forms";
import {Repository} from "../../../../../models/Repository";
import {Repository} from "../../../../../../api/models/Repository";
import {
RepositoryService
} from "../../../../../services/repository/repository.service";
@ -20,7 +20,7 @@ import {MatDialog} from "@angular/material/dialog";
})
export class RepositoryFormComponent implements OnInit {
@Input() name: string = "My Repository";
@Input() name: string = "My RepositoryData";
@Input() repositoryType: "local" | "remote" = "local";
@Input() path: string = "";
@Input() address: string = "";
@ -120,7 +120,7 @@ export class RepositoryFormComponent implements OnInit {
"repositoryType")?.value ?? "remote";
if (repositoryType === "remote") {
const match = /(\d+\.){3}\d+:\d+|\S+:\d+/.test(control.value)
const match = /(\d+\.){3}\d+:\d+|\S+:\d+/.test(control.value);
return match ? null : {invalidAddress: control.value};
}

@ -16,7 +16,7 @@ import {MatSelectModule} from "@angular/material/select";
import {MatInputModule} from "@angular/material/input";
import {ReactiveFormsModule} from "@angular/forms";
import {NgIconsModule} from "@ng-icons/core";
import {MatFolder} from "@ng-icons/material-icons"
import {MatFolder} from "@ng-icons/material-icons";
@NgModule({

@ -1,5 +1,5 @@
import {Component, EventEmitter, Output} from "@angular/core";
import {File} from "../../../../models/File";
import {File} from "../../../../../api/models/File";
@Component({
selector: "app-file-import",

@ -1,12 +1,10 @@
import {Component, EventEmitter, Output} from "@angular/core";
import {FileOsMetadata} from "../../../../../models/FileOsMetadata";
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 "../../../../../models/File";
import {File} from "../../../../../../api/models/File";
import {DialogFilter} from "@tauri-apps/api/dialog";
import {FileOsMetadata} from "../../../../../../api/api-types/files";
@Component({
selector: "app-filesystem-import",
@ -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);

@ -2,7 +2,7 @@
<app-metadata-entry *ngIf="mode === 'read'" [attributeName]="attributeName">{{value}}</app-metadata-entry>
<mat-form-field *ngIf="mode === 'write'">
<mat-label>{{attributeName}}</mat-label>
<input [formControl]="formControl" type="text" matInput [value]="value">
<input [formControl]="formControl" type="text" matInput [value]="value.toString()">
</mat-form-field>
<button *ngIf="mode === 'write'" mat-button (click)="this.onSave()">
<ng-icon name="mat-save"></ng-icon>

@ -1,4 +1,12 @@
import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core";
import {
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges
} from "@angular/core";
import {FormControl} from "@angular/forms";
@Component({
@ -6,7 +14,7 @@ import {FormControl} from "@angular/forms";
templateUrl: "./editable-metadata-entry.component.html",
styleUrls: ["./editable-metadata-entry.component.scss"]
})
export class EditableMetadataEntryComponent implements OnInit{
export class EditableMetadataEntryComponent implements OnInit, OnChanges {
@Input() attributeName!: string;
@Input() value!: string | number;
@ -22,6 +30,12 @@ export class EditableMetadataEntryComponent implements OnInit{
this.formControl.setValue(this.value);
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["value"] || changes["mode"]) {
this.formControl.setValue(this.value);
}
}
public onSave(): void {
this.valueChangeEvent.emit(this.formControl.value);
this.mode = "read";

@ -3,16 +3,16 @@
<h1>File Metadata</h1>
<mat-divider></mat-divider>
</div>
<div class="file-metadata-entries-scroll-container">
<div class="file-metadata-entries">
<app-editable-metadata-entry attributeName="Name" [value]="file.name ?? ''" (valueChangeEvent)="this.saveFileName($event)"></app-editable-metadata-entry>
<app-metadata-entry attributeName="Hash">{{file.hash}}</app-metadata-entry>
<app-metadata-entry attributeName="Mime Type">{{file.mime_type ?? 'unknown'}}</app-metadata-entry>
<app-metadata-entry attributeName="Imported at">{{file.import_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry attributeName="Created at">{{file.creation_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry attributeName="Changed at">{{file.change_time.toLocaleString()}}</app-metadata-entry>
<app-busy-indicator [blurBackground]="true" [busy]="this.loading" [darkenBackground]="false">
<div class="file-metadata-entries-scroll-container">
<div class="file-metadata-entries">
<app-editable-metadata-entry *ngIf="fileMetadata" attributeName="Name" [value]="fileMetadata.name ?? ''" (valueChangeEvent)="this.saveFileName($event)"></app-editable-metadata-entry>
<app-metadata-entry attributeName="Content Descriptor (CD)">{{file.cd}}</app-metadata-entry>
<app-metadata-entry attributeName="Mime Type">{{file.mimeType}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Imported at">{{fileMetadata.import_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Created at">{{fileMetadata.creation_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Changed at">{{fileMetadata.change_time.toLocaleString()}}</app-metadata-entry>
</div>
</div>
</div>
</app-busy-indicator>
</div>

@ -1,21 +1,48 @@
import {Component, Input} from "@angular/core";
import {File} from "../../../../models/File";
import {
Component,
Input,
OnChanges,
OnInit,
SimpleChanges
} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service";
import {FileMetadata} from "../../../../../api/api-types/files";
@Component({
selector: "app-file-metadata",
templateUrl: "./file-metadata.component.html",
styleUrls: ["./file-metadata.component.scss"]
})
export class FileMetadataComponent {
export class FileMetadataComponent implements OnInit, OnChanges {
@Input() file!: File;
public fileMetadata: FileMetadata | undefined;
public loading = false;
constructor(private fileService: FileService) {
}
public async ngOnInit() {
this.loading = true;
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false;
}
public async ngOnChanges(changes:SimpleChanges) {
if (changes["file"] && (!this.fileMetadata || this.fileMetadata.file_id != this.file.id)) {
this.loading = true;
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false;
}
}
public async saveFileName(name: string) {
const newFile = await this.fileService.updateFileName(this.file, name);
this.file.name = newFile.name;
this.loading = true;
const newFile = await this.fileService.updateFileName(this.file.id, name);
if (this.fileMetadata) {
this.fileMetadata.name = newFile.name;
}
this.loading = false;
}
}

@ -1,11 +1,23 @@
<div class="search-tab-inner" fxLayout="column">
<div id="file-search-input">
<div class="status-selector">
<mat-checkbox (change)="this.setDisplayImported($event)" [checked]="this.displayImported">Imported
</mat-checkbox>
<mat-checkbox (change)="this.setDisplayArchived($event)" [checked]="this.displayArchived">Archived
</mat-checkbox>
<mat-checkbox (change)="this.setDisplayDeleted($event)" [checked]="this.displayDeleted">Deleted
</mat-checkbox>
</div>
<div class="tag-input-list-and-actions">
<div #tagInputList class="tag-input-list">
<div class="tag-input-list-inner">
<div (click)="removeFilterExpression(filter)" *ngFor="let filter of filters" class="tag-input-item"
mat-ripple>{{filter.getDisplayName()}}</div>
<div (click)="removeFilterExpression(filter)"
(contextmenu)="this.contextMenuFilters.onContextMenu($event); this.contextMenuFilter = filter"
*ngFor="let filter of this.displayedFilters"
class="tag-input-item"
mat-ripple>
<app-filter-expression-item [filter]="filter"></app-filter-expression-item>
</div>
</div>
</div>
@ -14,13 +26,14 @@
</button>
</div>
<app-tag-input [allowWildcards]="true" (tagAdded)="addSearchTag($event); searchForFiles()" [allowNegation]="true"
[availableTags]="getValidSearchTags()"
class="full-width">
<app-filter-input (filterAdded)="addFilterExpression($event); searchForFiles()"
[availableTags]="getValidSearchTags()"
[value]="this.initialFilterInputValue"
class="full-width">
<button (click)="openFilterDialog()" class="filter-dialog-button" mat-button>
<ng-icon name="mat-filter-alt"></ng-icon>
</button>
</app-tag-input>
</app-filter-input>
<button (click)="openSortDialog()" id="sort-button" mat-flat-button>Sort: {{sortExpression.join(", ")}}</button>
</div>
@ -32,13 +45,16 @@
</div>
<div class="file-tag-list" fxFlex fxFlexAlign="start" fxFlexFill>
<cdk-virtual-scroll-viewport itemSize="50" maxBufferPx="4000" minBufferPx="500">
<div (click)="addSearchTag(tag.getNormalizedOutput())"
(contextmenu)="contextMenuTag = tag; contextMenu.onContextMenu($event)"
*cdkVirtualFor="let tag of contextTags" class="selectable-tag">
<app-tag-item [tag]="tag"></app-tag-item>
</div>
</cdk-virtual-scroll-viewport>
<app-busy-indicator [blurBackground]="true" [busy]="this.tagsLoading" [darkenBackground]="false">
<cdk-virtual-scroll-viewport itemSize="50" maxBufferPx="4000" minBufferPx="500">
<div (click)="addTagFilter(tag.getNormalizedOutput())"
(contextmenu)="contextMenuTag = tag; contextMenu.onContextMenu($event)"
*cdkVirtualFor="let tag of contextTags" class="selectable-tag">
<app-tag-item [tag]="tag"></app-tag-item>
</div>
</cdk-virtual-scroll-viewport>
</app-busy-indicator>
</div>
</div>
@ -56,3 +72,12 @@
</button>
</app-context-menu>
<app-context-menu #contextMenuFilters>
<button (click)="this.removeFilterExpression(this.contextMenuFilter!)" *ngIf="contextMenuFilter" mat-menu-item>
Remove
</button>
<button (click)="this.removeFilterExpression(this.contextMenuFilter!); this.addFilterToInput(this.contextMenuFilter!)"
*ngIf="contextMenuFilter"
mat-menu-item>Edit
</button>
</app-context-menu>

@ -119,3 +119,16 @@ mat-divider {
margin: auto;
}
}
.file-tag-list {
position: relative;
}
.status-selector {
display: flex;
align-items: center;
justify-content: space-between;
width: calc(100% - 2em);
margin-left: 1em;
margin-right: 1em;
}

@ -1,28 +1,18 @@
import {
AfterViewChecked,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from "@angular/core";
import {TagQuery} from "../../../../models/TagQuery";
import {AfterViewChecked, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from "@angular/core";
import {SortKey} from "../../../../models/SortKey";
import {MatDialog} from "@angular/material/dialog";
import {SortDialogComponent} from "./sort-dialog/sort-dialog.component";
import {
ErrorBrokerService
} from "../../../../services/error-broker/error-broker.service";
import {
FilterExpression,
SingleFilterExpression
} from "../../../../models/FilterExpression";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component";
import {Tag} from "../../../../models/Tag";
import {Tag} from "../../../../../api/models/Tag";
import {clipboard} from "@tauri-apps/api";
import {TabState} from "../../../../models/TabState";
import {FilterQueryBuilder} from "../../../../../api/models/FilterQueryBuilder";
import {SearchFilters} from "../../../../../api/models/SearchFilters";
import {FileStatus, FilterExpression,} from "../../../../../api/api-types/files";
import {filterExpressionToString} from "../../../../utils/filter-utils";
import {MatCheckboxChange} from "@angular/material/checkbox";
import * as deepEqual from "fast-deep-equal";
@Component({
@ -32,34 +22,53 @@ import {TabState} from "../../../../models/TabState";
})
export class FileSearchComponent implements AfterViewChecked, OnInit {
public sortExpression: SortKey[] = [];
public filters: FilterExpression[] = [];
public filters: SearchFilters = new SearchFilters([]);
@Input() availableTags: Tag[] = [];
@Input() contextTags: Tag[] = [];
@Input() state!: TabState;
@Input() tagsLoading = false;
@Output() searchStartEvent = new EventEmitter<void>();
@Output() searchEndEvent = new EventEmitter<void>();
@ViewChild("tagInput") tagInput!: ElementRef<HTMLInputElement>;
@ViewChild("tagInputList") inputList!: ElementRef;
public contextMenuTag: Tag | undefined;
public contextMenuFilter: FilterExpression | undefined = undefined;
public initialFilterInputValue: string | undefined;
public displayedFilters: FilterExpression[] = [];
public displayImported = true;
public displayArchived = true;
public displayDeleted = false;
private needsScroll = false;
constructor(
private errorBroker: ErrorBrokerService,
public dialog: MatDialog
) {
this.assignDisplayedFilters();
}
public async ngOnInit() {
this.state.filters.subscribe(f => this.filters = f);
this.state.filters.subscribe(f => {
this.filters = f;
this.assignDisplayedFilters();
});
this.state.sortKeys.subscribe(s => this.sortExpression = s);
this.applyStatusFromFilters();
await this.searchForFiles();
this.needsScroll = true;
this.assignDisplayedFilters();
}
public ngAfterViewChecked(): void {
this.inputList.nativeElement.scrollLeft = this.inputList.nativeElement.scrollWidth;
if (this.needsScroll) {
this.inputList.nativeElement.scrollLeft = this.inputList.nativeElement.scrollWidth;
this.needsScroll = false;
}
}
public async searchForFiles() {
@ -72,40 +81,47 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
this.searchEndEvent.emit();
}
public addSearchTag(tag: string) {
this.filters.push(new SingleFilterExpression(TagQuery.fromString(tag)));
tag = tag.replace(/^-/g, "");
public addFilterExpression(filter: FilterExpression) {
this.filters.removeFilter(filter);
this.filters.addFilterExpression(filter);
if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) {
const index = this.filters.findIndex(t => t.partiallyEq(tag));
this.filters.splice(index, 1);
this.state.setTagFilters(this.filters);
this.needsScroll = true;
}
public addTagFilter(filterString: string) {
const filter = FilterQueryBuilder.buildFilterFromString(filterString);
if (filter) {
this.addFilterExpression({ Query: filter });
}
this.state.setFilters(this.filters);
}
public getValidSearchTags(): Tag[] {
return this.availableTags.filter(t => this.filters.findIndex(
f => f.partiallyEq(t.getNormalizedOutput())) < 0);
return this.availableTags.filter(t => !this.filters.hasFilter({
Query: FilterQueryBuilder.tag(
t.getNormalizedOutput(),
false
)
}));
}
public async removeAllSearchTags() {
this.filters = [];
this.state.setFilters([]);
this.filters = new SearchFilters([]);
this.state.setTagFilters(this.filters);
}
public async removeFilterExpression(expr: FilterExpression) {
const index = this.filters.indexOf(expr);
if (index >= 0) {
this.filters.splice(index, 1);
}
this.state.setFilters(this.filters);
this.filters.removeFilter(expr);
this.state.setTagFilters(this.filters);
this.needsScroll = true;
}
public openSortDialog() {
const sortEntries = this.sortExpression.map(
key => JSON.parse(JSON.stringify(key))).map(
key => new SortKey(key.sortType, key.sortDirection,
key.namespaceName))
key.namespaceName
));
const openedDialog = this.dialog.open(SortDialogComponent, {
minWidth: "40vw",
data: {
@ -122,12 +138,13 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
}
public openFilterDialog(): void {
const filterEntries = this.filters.map(f => f.clone());
const filterEntries = new SearchFilters(JSON.parse(JSON.stringify(this.filters.getFilters())));
const filterDialog = this.dialog.open(FilterDialogComponent, {
minWidth: "25vw",
maxHeight: "80vh",
data: {
filterEntries,
filters: filterEntries,
availableTags: this.availableTags,
},
disableClose: true,
@ -135,7 +152,9 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
filterDialog.afterClosed().subscribe(async (filterExpression) => {
if (filterExpression !== undefined || filterExpression?.length > 0) {
this.filters = filterExpression;
this.state.setFilters(this.filters);
this.applyStatusFromFilters();
this.state.setTagFilters(this.filters);
this.needsScroll = true;
}
});
}
@ -143,4 +162,77 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
public async copyToClipboard(text: string) {
await clipboard.writeText(text);
}
public addFilterToInput(param: FilterExpression): void {
this.initialFilterInputValue = filterExpressionToString(param);
}
public setDisplayDeleted(event: MatCheckboxChange) {
this.displayDeleted = event.checked;
this.updateStatusFilters();
}
public setDisplayArchived(event: MatCheckboxChange) {
this.displayArchived = event.checked;
this.updateStatusFilters();
}
public setDisplayImported(event: MatCheckboxChange) {
this.displayImported = event.checked;
this.updateStatusFilters();
}
public isTagFilter(filter: FilterExpression): boolean {
const tagFilter = this.buildFilterForDisplayProperty();
return deepEqual(tagFilter, filter);
}
private assignDisplayedFilters() {
this.displayedFilters = this.filters.getFilters().filter(f => !this.isTagFilter(f));
}
private applyStatusFromFilters() {
const filterImported = FilterQueryBuilder.status("Imported");
const filterArchived = FilterQueryBuilder.status("Archived");
const filterDeleted = FilterQueryBuilder.status("Deleted");
this.displayImported = this.filters.hasSubfilter(filterImported);
this.displayArchived = this.filters.hasSubfilter(filterArchived);
this.displayDeleted = this.filters.hasSubfilter(filterDeleted);
if (!this.displayImported && !this.displayDeleted && !this.displayArchived) {
this.displayImported = true;
this.displayArchived = true;
}
this.updateStatusFilters();
}
private updateStatusFilters() {
this.deleteAllStatusFilters();
const filter = this.buildFilterForDisplayProperty();
this.filters.addFilter(filter, 0);
this.state.setTagFilters(this.filters);
}
private deleteAllStatusFilters() {
for (const status of ["Imported", "Archived", "Deleted"]) {
const query = FilterQueryBuilder.status(status as FileStatus);
this.filters.removeSubfilter(query);
this.filters.removeFilter({ Query: query });
}
this.state.setTagFilters(this.filters);
}
private buildFilterForDisplayProperty(): FilterExpression {
const filters = [];
if (this.displayImported) {
filters.push(FilterQueryBuilder.status("Imported"));
}
if (this.displayArchived) {
filters.push(FilterQueryBuilder.status("Archived"));
}
if (this.displayDeleted) {
filters.push(FilterQueryBuilder.status("Deleted"));
}
return { OrExpression: filters };
}
}

@ -2,26 +2,30 @@
<div class="filter-dialog-content" mat-dialog-content>
<div class="filter-dialog-list">
<mat-list class="mat-filter-dialog-list">
<mat-list-item *ngFor="let expression of filters" [class.selected]="expression.selected"
class="filter-list-item">
<app-tag-filter-list-item (contextmenu)="contextMenu.onContextMenu($event)"
(querySelect)="this.addToSelection($event)"
(queryUnselect)="this.removeFromSelection($event)"
(removeClicked)="this.removeFilter($event)"
[expression]="expression"></app-tag-filter-list-item>
<mat-list-item *ngFor="let entry of this.renderedFilterEntries" class="filter-list-item">
<app-filter-expression-list-item (appSelect)="this.entrySelect(entry[0])"
(appUnselect)="this.entryUnselect(entry[0])"
(contextmenu)="contextMenu.onContextMenu($event)"
(entrySelect)="this.entrySelect(entry[0], $event[0])"
(entryUnselect)="this.entryUnselect(entry[0], $event[0])"
[filter]="entry[1]"
></app-filter-expression-list-item>
</mat-list-item>
</mat-list>
</div>
<mat-divider></mat-divider>
<app-tag-input [allowWildcards]="true" (tagAdded)="this.addFilter($event)" [allowNegation]="true" [availableTags]="this.availableTags"
class="tag-input"></app-tag-input>
<app-filter-input (filterAdded)="this.addFilter($event)"
[availableTags]="this.availableTags"
class="filter-input"></app-filter-input>
</div>
<div class="dialog-actions" mat-dialog-actions>
<button (click)="confirmFilter()" color="primary" mat-flat-button>Filter</button>
<button (click)="cancelFilter()" color="accent" mat-stroked-button>Cancel</button>
</div>
<app-context-menu #contextMenu>
<button (click)="this.convertSelectionToOrExpression()" mat-menu-item>Copy to OR-Expression</button>
<button (click)="this.convertSelectionToAndExpression()" mat-menu-item>Copy to AND-Expression</button>
<button (click)="this.invertSelection()" mat-menu-item>Invert</button>
<button (click)="this.removeSelectedFilters()" mat-menu-item>Remove</button>
<button (click)="this.createAndFromSelection(true)" mat-menu-item>Convert selection to AND</button>
<button (click)="this.createOrFromSelection(true)" mat-menu-item>Convert selection to OR</button>
<button (click)="this.createOrFromSelection(false)" mat-menu-item>Copy selection to OR</button>
<button (click)="this.createAndFromSelection(false)" mat-menu-item>Copy selection to AND</button>
</app-context-menu>

@ -8,7 +8,7 @@
}
}
.tag-input {
.filter-input {
width: 100%;
height: 5em;
}
@ -20,14 +20,10 @@ mat-list-item.filter-list-item {
cursor: pointer;
}
app-tag-filter-list-item {
app-filter-expression-list-item {
width: 100%;
}
.selected {
background-color: #5c5c5c;
}
.filter-dialog-content {
overflow: hidden;
width: 100%;

@ -1,58 +1,37 @@
import {Component, HostListener, Inject, ViewChildren} from "@angular/core";
import {Component, Inject, OnChanges, SimpleChanges} 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";
import {Tag} from "../../../../../../api/models/Tag";
import {SearchFilters} from "../../../../../../api/models/SearchFilters";
import {FilterExpression, FilterQuery} from "../../../../../../api/api-types/files";
import {enumerate, removeByValue} from "../../../../../utils/list-utils";
type IndexableSelection<T> = {
[key: number]: T
};
@Component({
selector: "app-filter-dialog",
templateUrl: "./filter-dialog.component.html",
styleUrls: ["./filter-dialog.component.scss"]
})
export class FilterDialogComponent {
public filters: Selectable<FilterExpression>[];
export class FilterDialogComponent implements OnChanges {
public availableTags: Tag[] = [];
public mode: "AND" | "OR" = "AND";
@ViewChildren(
TagFilterListItemComponent) filterListItems!: TagFilterListItemComponent[];
private selectedQueries: TagQuery[] = [];
public filters = new SearchFilters([]);
public renderedFilterEntries: [number, FilterExpression][] = [];
private selectedIndices: IndexableSelection<number[]> = {};
constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject(
MAT_DIALOG_DATA) data: any) {
this.filters = data.filterEntries.map(
(f: FilterExpression) => new Selectable<FilterExpression>(f,
false)) ?? [];
this.availableTags = data.availableTags ?? [];
this.filters = data.filters;
this.buildRenderedEntries();
}
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;
}
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["filters"]) {
this.buildRenderedEntries();
}
return match;
}
public cancelFilter(): void {
@ -60,118 +39,105 @@ export class FilterDialogComponent {
}
public confirmFilter(): void {
this.dialogRef.close(this.filters.map(f => f.data));
this.dialogRef.close(this.filters);
}
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 entrySelect(index: number, subindex: number = -1): void {
this.selectedIndices[index] = this.selectedIndices[index] ?? [];
this.selectedIndices[index].push(subindex);
}
public addFilter(tag: string) {
const query = TagQuery.fromString(tag);
if (this.mode === "AND" || this.filters.length === 0) {
this.filters.push(
new Selectable<FilterExpression>(
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>(filterExpression,
false));
}
this.unselectAll();
public entryUnselect(index: number, subindex: number = -1): void {
this.selectedIndices[index] = this.selectedIndices[index] ?? [];
removeByValue(this.selectedIndices[index], subindex);
}
public addToSelection(query: TagQuery): void {
this.selectedQueries.push(query);
public addFilter(expression: FilterExpression): void {
this.filters.addFilterExpression(expression);
this.buildRenderedEntries();
}
public removeFromSelection(query: TagQuery): void {
const index = this.selectedQueries.indexOf(query);
if (index > 0) {
this.selectedQueries.splice(index, 1);
}
}
public removeSelectedFilters(): void {
const orderedIndices = Object.keys(this.selectedIndices).map(k => Number(k)).sort().reverse();
public unselectAll() {
this.filters.forEach(filter => filter.selected = false);
this.selectedQueries = [];
this.filterListItems.forEach(i => i.selectedIndices = []);
}
for (const indexStr of orderedIndices) {
const index = indexStr;
const subIndices: number[] = this.selectedIndices[index];
public convertSelectionToAndExpression(): void {
for (const query of this.selectedQueries) {
this.filters.push(
new Selectable<FilterExpression>(
new SingleFilterExpression(query),
false));
if (subIndices.length === 1 && subIndices[0] === -1) {
this.filters.removeFilterAtIndex(index);
} else if (subIndices.length > 0) {
for (const subIndex of subIndices.sort().reverse()) { // need to remove from the top down to avoid index shifting
this.filters.removeSubfilterAtIndex(index, subIndex);
}
}
}
this.removeFilterDuplicates();
this.unselectAll();
this.selectedIndices = {};
this.buildRenderedEntries();
}
public convertSelectionToOrExpression(): void {
const queries = this.selectedQueries;
const expression = new OrFilterExpression(queries);
this.filters.push(new Selectable<FilterExpression>(expression, false));
this.removeFilterDuplicates();
this.unselectAll();
public createAndFromSelection(deleteOriginal: boolean): void {
const expressions: FilterExpression[] = [];
for (const indexStr in this.selectedIndices) {
const index = Number(indexStr);
const subindices = this.selectedIndices[index];
if (subindices.length === 1 && subindices[0] === -1) {
expressions.push(this.filters.getFilters()[index]);
} else {
for (const subIndex of subindices) {
const query = this.filters.getSubfilterAtIndex(index, subIndex);
if (query) {
expressions.push({ Query: query });
}
}
}
}
if (deleteOriginal) {
this.removeSelectedFilters();
} else {
this.selectedIndices = {};
}
expressions.forEach(e => this.filters.addFilterExpression(e));
this.buildRenderedEntries();
}
public invertSelection(): void {
this.selectedQueries.forEach(query => query.negate = !query.negate);
}
public createOrFromSelection(deleteOriginal: boolean): void {
const queries: FilterQuery[] = [];
private removeFilterDuplicates() {
const filters = this.filters;
let newFilters: Selectable<FilterExpression>[] = [];
for (const indexStr in this.selectedIndices) {
const index = Number(indexStr);
const subindices = this.selectedIndices[index];
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]);
if (subindices.length === 1 && subindices[0] === -1) {
const filterEntry = this.filters.getFilters()[index];
if ("Query" in filterEntry) {
queries.push(filterEntry.Query);
}
} else {
for (const subIndex of subindices) {
const query = this.filters.getSubfilterAtIndex(index, subIndex);
if (query) {
queries.push(query);
}
}
newFilters.push(filterItem);
}
}
this.filters = newFilters;
}
@HostListener("window:keydown", ["$event"])
private async handleKeydownEvent(event: KeyboardEvent) {
if (event.key === "Shift") {
this.mode = "OR";
if (deleteOriginal) {
this.removeSelectedFilters();
} else {
this.selectedIndices = {};
}
if (queries.length > 1) {
this.filters.addFilterExpression({ OrExpression: queries });
} else if (queries.length === 1) {
this.filters.addFilterExpression({ Query: queries[0] });
}
this.buildRenderedEntries();
}
@HostListener("window:keyup", ["$event"])
private async handleKeyupEvent(event: KeyboardEvent) {
if (event.key === "Shift") {
this.mode = "AND";
}
private buildRenderedEntries() {
this.renderedFilterEntries = enumerate(this.filters.getFilters());
}
}

@ -0,0 +1,27 @@
<span *ngIf="this.orExpression" class="or-expression">
<mat-list>
<mat-list-item *ngFor="let entry of this.orExpression"
[class.selected]="componentSelectable.selected"
class="or-filter-list-item">
<span *ngIf="entry[0] > 0" class="or-combinator">OR&nbsp;</span>
<app-selectable #componentSelectable
(appSelect)="this.entrySelect.emit(entry)"
(appUnselect)="this.entryUnselect.emit(entry)">
<app-property-query-item *ngIf="this.queryIs(entry[1], 'Property')"
[propertyQuery]="this.propertyQuery(entry[1]).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(entry[1], 'Tag')"
[tagQuery]="this.tagQuery(entry[1]).Tag"></app-tag-query-item>
</app-selectable>
</mat-list-item>
</mat-list>
</span>
<span *ngIf="this.query" [class.selected]="singleSelectable.selected" class="query">
<app-selectable #singleSelectable
(appSelect)="this.appSelect.emit(this.query)"
(appUnselect)="this.appUnselect.emit(this.query)">
<app-property-query-item *ngIf="this.queryIs(this.query, 'Property')"
[propertyQuery]="this.propertyQuery(this.query).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(this.query, 'Tag')"
[tagQuery]="this.tagQuery(this.query).Tag"></app-tag-query-item>
</app-selectable>
</span>

@ -1,14 +1,3 @@
.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%;
@ -32,10 +21,15 @@ mat-list-item.or-filter-list-item {
}
}
.or-span {
margin-right: 0.5em;
}
.selected {
background-color: #5c5c5c;
}
app-selectable {
width: 100%;
height: 100%;
}
.or-combinator {
color: #7dff70;
}

@ -0,0 +1,25 @@
import {ComponentFixture, TestBed} from "@angular/core/testing";
import {FilterExpressionListItemComponent} from "./filter-expression-list-item.component";
describe("FilterExpressionListItemComponent", () => {
let component: FilterExpressionListItemComponent;
let fixture: ComponentFixture<FilterExpressionListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [FilterExpressionListItemComponent]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FilterExpressionListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,57 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {
FilterExpression,
FilterQuery,
FilterQueryProperty,
FilterQueryTag
} from "../../../../../../../api/api-types/files";
import {enumerate} from "../../../../../../utils/list-utils";
@Component({
selector: "app-filter-expression-list-item",
templateUrl: "./filter-expression-list-item.component.html",
styleUrls: ["./filter-expression-list-item.component.scss"]
})
export class FilterExpressionListItemComponent implements OnChanges {
@Input() filter!: FilterExpression;
@Output() entrySelect = new EventEmitter<[number, FilterQuery]>();
@Output() entryUnselect = new EventEmitter<[number, FilterQuery]>();
@Output() appSelect = new EventEmitter<FilterQuery>();
@Output() appUnselect = new EventEmitter<FilterQuery>();
public orExpression: undefined | [number, FilterQuery][] = undefined;
public query: undefined | FilterQuery = undefined;
constructor() {
this.parseFilter();
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["filter"]) {
this.parseFilter();
}
}
public queryIs(query: FilterQuery, key: "Property" | "Tag"): boolean {
return key in query;
}
public propertyQuery(query: FilterQuery): FilterQueryProperty {
return query as FilterQueryProperty;
}
public tagQuery(query: FilterQuery): FilterQueryTag {
return query as FilterQueryTag;
}
private parseFilter() {
if (this.filter && "OrExpression" in this.filter) {
this.orExpression = enumerate(this.filter.OrExpression);
} else if (this.filter) {
this.query = this.filter.Query;
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save