Merge pull request #3 from Trivernis/feature/editing-tags

Feature/editing tags
pull/4/head
Julius Riegel 3 years ago committed by GitHub
commit e61624ce6e

@ -1580,8 +1580,8 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]] [[package]]
name = "mediarepo-api" name = "mediarepo-api"
version = "0.2.0" version = "0.4.1"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=a86cd413637562987a3940a664b923fd7e726cef#a86cd413637562987a3940a664b923fd7e726cef" source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=b123e69d92dfd384d37b9005c8e261c69fddfcc5#b123e69d92dfd384d37b9005c8e261c69fddfcc5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",

@ -30,7 +30,7 @@ features = ["env-filter"]
[dependencies.mediarepo-api] [dependencies.mediarepo-api]
git = "https://github.com/Trivernis/mediarepo-api.git" git = "https://github.com/Trivernis/mediarepo-api.git"
rev = "a86cd413637562987a3940a664b923fd7e726cef" rev = "b123e69d92dfd384d37b9005c8e261c69fddfcc5"
features = ["tauri-plugin"] features = ["tauri-plugin"]
[features] [features]

@ -48,6 +48,7 @@ import {ConfirmDialogComponent} from './components/confirm-dialog/confirm-dialog
import {FilesTabSidebarComponent} from './pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component'; import {FilesTabSidebarComponent} from './pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component';
import {MatExpansionModule} from "@angular/material/expansion"; import {MatExpansionModule} from "@angular/material/expansion";
import {TagItemComponent} from './components/tag-item/tag-item.component'; import {TagItemComponent} from './components/tag-item/tag-item.component';
import { FileEditComponent } from './components/file-edit/file-edit.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -67,6 +68,7 @@ import {TagItemComponent} from './components/tag-item/tag-item.component';
ConfirmDialogComponent, ConfirmDialogComponent,
FilesTabSidebarComponent, FilesTabSidebarComponent,
TagItemComponent, TagItemComponent,
FileEditComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

@ -0,0 +1,48 @@
<div class="file-edit-inner" fxLayout="column">
<div class="file-metadata" fxFlex="150px">
<h1>Edit File</h1>
<mat-form-field *ngIf="this.files.length === 1" appearance="fill">
<mat-label>Name</mat-label>
<input #fileNameInput matInput (focusout)="this.changeFileName(fileNameInput.value)">
</mat-form-field>
</div>
<div class="tag-edit-list" fxFlex fxFlexFill fxFlexAlign="start">
<cdk-virtual-scroll-viewport #tagScroll itemSize="50" maxBufferPx="2000" minBufferPx="1000">
<div class="editable-tag" *cdkVirtualFor="let tag of tags">
<app-tag-item [tag]="tag"></app-tag-item>
<button class="tag-remove-button" mat-icon-button (click)="removeTag(tag)">
<mat-icon>remove</mat-icon>
</button>
</div>
</cdk-virtual-scroll-viewport>
</div>
<mat-divider fxFlex="1em"></mat-divider>
<div class="tag-input" fxFlex="200px">
<div class="tag-input-field">
<mat-form-field class="form-field-tag-input" appearance="fill">
<mat-label>{{modeSelect.value}} tag</mat-label>
<input [formControl]="tagInputForm" matInput [matAutocomplete]="auto" (keydown)="this.handleTagInputKeydown($event)" (submit)="this.editTag(tagInputForm.value)">
</mat-form-field>
<mat-autocomplete #auto (optionSelected)="editTagByAutocomplete($event)">
<mat-option *ngFor="let tag of suggestionTags | async" [value]="tag">
{{tag}}
</mat-option>
</mat-autocomplete>
<button mat-icon-button class="add-tag-button">
<mat-icon *ngIf="editMode === 'Toggle'">change_circle</mat-icon>
<mat-icon *ngIf="editMode === 'Add'">add_circle</mat-icon>
<mat-icon *ngIf="editMode === 'Remove'">remove_circle</mat-icon>
</button>
</div>
<mat-form-field class="form-field-mode" appearance="fill">
<mat-label>Mode</mat-label>
<mat-select #modeSelect [(value)]="editMode">
<mat-option value="Toggle">Toggle</mat-option>
<mat-option value="Add">Add</mat-option>
<mat-option value="Remove">Remove</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>

@ -0,0 +1,70 @@
.file-metadata, .tag-input {
width: 100%;
mat-form-field {
width: 100%;
}
mat-form-field.form-field-mode {
width: 10em;
}
mat-form-field.form-field-tag-input {
}
}
.file-edit-inner {
height: 100%;
width: 100%;
display: block;
}
.tag-edit-list {
height: 100%;
width: 100%;
display: block;
overflow: hidden;
}
cdk-virtual-scroll-viewport {
height: 100%;
width: 100%;
overflow-y: auto;
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100%;
}
}
.editable-tag {
height: 50px;
width: 100%;
display: flex;
font-size: 1.2em;
transition-duration: 0.1s;
user-select: none;
overflow: hidden;
cursor: default;
app-tag-item {
margin: auto auto auto 0.25em;
}
.tag-remove-button {
margin-right: 1em;
height: 50px;
width: 50px;
}
}
.tag-input-field {
display: flex;
flex-direction: row;
.add-tag-button {
width: 65px;
height: 65px;
}
}
mat-divider {
width: 100%;
}

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

@ -0,0 +1,186 @@
import {
Component, ElementRef,
Input,
OnChanges,
OnInit,
SimpleChanges,
ViewChild
} from '@angular/core';
import {FormControl} from "@angular/forms";
import {File} from "../../models/File";
import {Tag} from "../../models/Tag";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {Observable} from "rxjs";
import {map, startWith} from "rxjs/operators";
import {TagService} from "../../services/tag/tag.service";
import {FileService} from "../../services/file/file.service";
@Component({
selector: 'app-file-edit',
templateUrl: './file-edit.component.html',
styleUrls: ['./file-edit.component.scss']
})
export class FileEditComponent implements OnInit, OnChanges {
@Input() files: File[] = [];
public tags: Tag[] = [];
private allTags: Tag[] = [];
private fileTags: {[key: number]: Tag[]} = {};
public suggestionTags: Observable<string[]>;
public tagInputForm = new FormControl("");
public editMode: string = "Toggle";
@ViewChild("tagScroll") tagScroll!: CdkVirtualScrollViewport;
@ViewChild("fileNameInput") fileNameInput!: ElementRef<HTMLInputElement>;
constructor(
private tagService: TagService,
private fileService: FileService,
) {
this.suggestionTags = this.tagInputForm.valueChanges.pipe(startWith(null),
map(
(tag: string | null) => tag ? this.filterSuggestionTag(
tag) : this.allTags.slice(0, 20).map(t => t.getNormalizedOutput())));
}
async ngOnInit() {
this.tagService.tags.subscribe(tags => this.allTags = tags);
await this.tagService.loadTags();
await this.loadFileTags();
this.resetFileNameInput();
}
async ngOnChanges(changes: SimpleChanges) {
if (changes["files"]) {
await this.loadFileTags()
this.resetFileNameInput();
}
}
public async changeFileName(value: string) {
const name = value.trim();
if (name.length > 0) {
const file = this.files[0];
console.log("Updating name to", name);
const responseFile = await this.fileService.updateFileName(file, name);
console.log("Updated name");
file.name = responseFile.name;
this.resetFileNameInput();
}
}
public async editTagByAutocomplete($event: MatAutocompleteSelectedEvent) {
const tag = $event.option.value.trim();
await this.editTag(tag);
}
public async editTag(tag: string): Promise<void> {
if (tag.length > 0) {
let tagInstance = this.allTags.find(t => t.getNormalizedOutput() === tag);
if (!tagInstance) {
tagInstance = (await this.tagService.createTags([tag]))[0];
this.allTags.push(tagInstance);
}
switch (this.editMode) {
case "Toggle":
await this.toggleTag(tagInstance);
break;
case "Add":
await this.addTag(tagInstance);
break;
case "Remove":
await this.removeTag(tagInstance);
break;
}
this.tagInputForm.setValue("");
}
}
async toggleTag(tag: Tag) {
for (const file of this.files) {
const fileTags = this.fileTags[file.id];
let addedTags = [];
let removedTags = [];
if (fileTags.findIndex(i => i.id === tag.id) < 0) {
addedTags.push(tag.id);
} else {
removedTags.push(tag.id);
}
this.fileTags[file.id] = await this.tagService.changeFileTags(file.id, addedTags, removedTags);
}
this.mapFileTagsToTagList();
const index = this.tags.indexOf(tag);
index >= 0 && this.tagScroll.scrollToIndex(index);
}
async addTag(tag: Tag) {
for (const file of this.files) {
if (this.fileTags[file.id].findIndex(t => t.id === tag.id) < 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags(file.id,
[tag.id], []);
}
}
if (this.tags.findIndex(t => t.getNormalizedOutput() === tag.getNormalizedOutput()) < 0) {
this.tags.push(tag);
this.tags = this.tags.sort(
(a, b) => a.getNormalizedOutput().localeCompare(b.getNormalizedOutput()));
this.tags = [...this.tags]; // angular pls detect it wtf
}
const index = this.tags.indexOf(tag);
index >= 0 && this.tagScroll.scrollToIndex(index);
}
public async removeTag(tag: Tag) {
for (const file of this.files) {
if (this.fileTags[file.id].findIndex(t => t.id === tag.id) >= 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags(file.id,
[], [tag.id]);
}
}
const index = this.tags.indexOf(tag);
if (index >= 0) {
this.tags.splice(index, 1);
this.tags = [...this.tags]; // so angular detects the change
}
}
private filterSuggestionTag(tag: string) {
const allTags = this.allTags.map(t => t.getNormalizedOutput());
return allTags.filter(
t => t.includes(tag))
.slice(0, 20);
}
private async loadFileTags() {
for (const file of this.files) {
this.fileTags[file.id] = await this.tagService.getTagsForFiles([file.hash]);
}
this.mapFileTagsToTagList();
}
private resetFileNameInput() {
if (this.files.length === 1) {
this.fileNameInput.nativeElement.value = this.files[0].name ?? "";
}
}
private mapFileTagsToTagList() {
let tags: Tag[] = [];
for (const file of this.files) {
const fileTags = this.fileTags[file.id];
tags.push(...fileTags.filter(t => tags.findIndex(tag => tag.id === t.id) < 0));
}
this.tags = tags.sort((a, b) => a.getNormalizedOutput().localeCompare(b.getNormalizedOutput()));
}
public async handleTagInputKeydown($event: KeyboardEvent) {
if ($event.key === "Enter") {
await this.editTag(this.tagInputForm.value);
}
}
}

@ -47,7 +47,7 @@ export class FileGalleryEntryComponent implements OnInit, OnChanges {
async loadImage() { async loadImage() {
try { try {
const hash = this.file.data.hash; const hash = this.file.data.hash;
const thumbnails = await this.fileService.getThumbnails(hash); const thumbnails = await this.fileService.getThumbnails(this.file.data);
let thumbnail = thumbnails.find( let thumbnail = thumbnails.find(
t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500)); t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500));
thumbnail = thumbnail ?? thumbnails[0]; thumbnail = thumbnail ?? thumbnails[0];

@ -1,6 +1,5 @@
<mat-card #card (click)="clickEvent.emit(this)" (dblclick)="dblClickEvent.emit(this)" <mat-card #card (click)="clickEvent.emit(this)" (dblclick)="dblClickEvent.emit(this)"
[ngClass]="{'selected': gridEntry.selected}"> [ngClass]="{'selected': gridEntry.selected}">
<mat-card-title *ngIf="!!gridEntry.file?.name">{{gridEntry.file?.name}}</mat-card-title>
<mat-card-content *ngIf="contentUrl !== undefined"> <mat-card-content *ngIf="contentUrl !== undefined">
<app-content-aware-image *ngIf="contentUrl" [imageSrc]="contentUrl" class="entry-image"></app-content-aware-image> <app-content-aware-image *ngIf="contentUrl" [imageSrc]="contentUrl" class="entry-image"></app-content-aware-image>
</mat-card-content> </mat-card-content>

@ -47,7 +47,7 @@ export class FileGridEntryComponent implements OnInit, OnChanges {
async loadImage() { async loadImage() {
try { try {
const thumbnails = await this.fileService.getThumbnails( const thumbnails = await this.fileService.getThumbnails(
this.gridEntry.file.hash); this.gridEntry.file);
let thumbnail = thumbnails.find( let thumbnail = thumbnails.find(
t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500)); t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500));
thumbnail = thumbnail ?? thumbnails[0]; thumbnail = thumbnail ?? thumbnails[0];

@ -135,7 +135,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
return this.validTags.filter( return this.validTags.filter(
t => t.includes(normalizedTag) && this.searchTags.findIndex( t => t.includes(normalizedTag) && this.searchTags.findIndex(
s => s.name === t) < 0) s => s.getNormalizedTag() === t) < 0)
.map(t => negated ? "-" + t : t) .map(t => negated ? "-" + t : t)
.slice(0, 20); .slice(0, 20);
} }

@ -2,6 +2,8 @@
display: inline; display: inline;
width: 100%; width: 100%;
padding: 0.25em; padding: 0.25em;
word-break: break-all;
text-overflow: ellipsis;
} }
.tag-item-namespace { .tag-item-namespace {

@ -21,5 +21,8 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab label="Edit" *ngIf="this.selectedFiles.length > 0">
<app-file-edit #fileedit [files]="this.selectedFiles"></app-file-edit>
</mat-tab>
</mat-tab-group> </mat-tab-group>
</div> </div>

@ -8,7 +8,7 @@ app-file-search {
overflow: hidden; overflow: hidden;
} }
mat-tab-group, mat-tab, .file-tag-list { mat-tab-group, mat-tab, .file-tag-list, app-file-edit {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
@ -45,17 +45,13 @@ mat-selection-list {
cursor: pointer; cursor: pointer;
} }
.file-tag-list-inner {
display: block;
height: 100%;
width: 100%;
overflow: hidden;
}
cdk-virtual-scroll-viewport { cdk-virtual-scroll-viewport {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100%;
}
} }
mat-divider { mat-divider {

@ -14,6 +14,7 @@ import {FileService} from "../../../../services/file/file.service";
import {File} from "../../../../models/File"; import {File} from "../../../../models/File";
import {FileSearchComponent} from "../../../../components/file-search/file-search.component"; import {FileSearchComponent} from "../../../../components/file-search/file-search.component";
import {RepositoryService} from "../../../../services/repository/repository.service"; import {RepositoryService} from "../../../../services/repository/repository.service";
import {FileEditComponent} from "../../../../components/file-edit/file-edit.component";
@Component({ @Component({
selector: 'app-files-tab-sidebar', selector: 'app-files-tab-sidebar',
@ -27,10 +28,12 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
@Output() searchEndEvent = new EventEmitter<void>(); @Output() searchEndEvent = new EventEmitter<void>();
@ViewChild('filesearch') fileSearch!: FileSearchComponent; @ViewChild('filesearch') fileSearch!: FileSearchComponent;
@ViewChild("fileedit") fileEdit: FileEditComponent | undefined;
public tagsOfFiles: Tag[] = []; public tagsOfFiles: Tag[] = [];
public tags: Tag[] = []; public tags: Tag[] = [];
public files: File[] = []; public files: File[] = [];
public tagsOfSelection: Tag[] = [];
constructor(private repoService: RepositoryService, private tagService: TagService, private fileService: FileService) { constructor(private repoService: RepositoryService, private tagService: TagService, private fileService: FileService) {
this.fileService.displayedFiles.subscribe(async files => { this.fileService.displayedFiles.subscribe(async files => {
@ -72,9 +75,10 @@ export class FilesTabSidebarComponent implements OnInit, OnChanges {
} }
async showFileDetails(files: File[]) { async showFileDetails(files: File[]) {
this.tags = await this.tagService.getTagsForFiles(files.map(f => f.hash)) this.tagsOfSelection = await this.tagService.getTagsForFiles(files.map(f => f.hash))
this.tags = this.tags.sort( this.tagsOfSelection = this.tagsOfSelection.sort(
(a, b) => a.getNormalizedOutput().localeCompare(b.getNormalizedOutput())); (a, b) => a.getNormalizedOutput().localeCompare(b.getNormalizedOutput()));
this.tags = this.tagsOfSelection;
} }
private async refreshFileSelection() { private async refreshFileSelection() {

@ -31,7 +31,7 @@ export class FileService {
public async readFile(file: File): Promise<SafeResourceUrl> { public async readFile(file: File): Promise<SafeResourceUrl> {
const once_uri = await invoke<string>("plugin:mediarepo|read_file_by_hash", const once_uri = await invoke<string>("plugin:mediarepo|read_file_by_hash",
{hash: file.hash, mimeType: file.mime_type}); {id: file.id, hash: file.hash, mimeType: file.mime_type});
return this.sanitizer.bypassSecurityTrustResourceUrl(once_uri); return this.sanitizer.bypassSecurityTrustResourceUrl(once_uri);
} }
@ -41,8 +41,12 @@ export class FileService {
return this.sanitizer.bypassSecurityTrustResourceUrl(once_uri); return this.sanitizer.bypassSecurityTrustResourceUrl(once_uri);
} }
public async getThumbnails(hash: string): Promise<Thumbnail[]> { public async getThumbnails(file: File): Promise<Thumbnail[]> {
return await invoke<Thumbnail[]>("plugin:mediarepo|get_file_thumbnails", return await invoke<Thumbnail[]>("plugin:mediarepo|get_file_thumbnails",
{hash}); {id: file.id});
}
public async updateFileName(file: File, name: string): Promise<File> {
return await invoke<File>("plugin:mediarepo|update_file_name", {id: file.id, name})
} }
} }

@ -2,6 +2,7 @@ import {Injectable} from '@angular/core';
import {invoke} from "@tauri-apps/api/tauri"; import {invoke} from "@tauri-apps/api/tauri";
import {Tag} from "../../models/Tag"; import {Tag} from "../../models/Tag";
import {BehaviorSubject} from "rxjs"; import {BehaviorSubject} from "rxjs";
import {File} from "../../models/File";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -18,21 +19,22 @@ export class TagService {
this.tags.next(tags.map(t => new Tag(t.id, t.name, t.namespace))); this.tags.next(tags.map(t => new Tag(t.id, t.name, t.namespace)));
} }
public async getTagsForFile(hash: string): Promise<Tag[]> {
const tags = await invoke<Tag[]>("plugin:mediarepo|get_tags_for_file",
{hash});
return tags.map(t => new Tag(t.id, t.name, t.namespace));
}
public async getTagsForFiles(hashes: string[]): Promise<Tag[]> { public async getTagsForFiles(hashes: string[]): Promise<Tag[]> {
let tags: Tag[] = [] let tags: Tag[] = []
if (hashes.length === 1) { if (hashes.length > 0) {
tags = await invoke<Tag[]>("plugin:mediarepo|get_tags_for_file",
{hash: hashes[0]});
} else if (hashes.length > 0) {
tags = await invoke<Tag[]>("plugin:mediarepo|get_tags_for_files", tags = await invoke<Tag[]>("plugin:mediarepo|get_tags_for_files",
{hashes}); {hashes});
} }
return tags.map(t => new Tag(t.id, t.name, t.namespace)); return tags.map(t => new Tag(t.id, t.name, t.namespace));
} }
public async createTags(tags: string[]): Promise<Tag[]> {
const resultTags = await invoke<Tag[]>("plugin:mediarepo|create_tags", {tags});
return resultTags.map(t => new Tag(t.id, t.name, t.namespace));
}
public async changeFileTags(fileId: number, addedTags: number[], removedTags: number[]): Promise<Tag[]> {
const tags = await invoke<Tag[]>("plugin:mediarepo|change_file_tags", {id: fileId, addedTags, removedTags});
return tags.map(t => new Tag(t.id, t.name, t.namespace));
}
} }

Loading…
Cancel
Save