Merge pull request #4 from Trivernis/feature/filesystem-imports

Feature/filesystem imports
pull/4/head
Julius Riegel 3 years ago committed by GitHub
commit dcd20fd758

@ -1580,12 +1580,13 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "mediarepo-api"
version = "0.5.1"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=b6a287e200eeae1105466ae7851a3a9287efe34b#b6a287e200eeae1105466ae7851a3a9287efe34b"
version = "0.6.0"
source = "git+https://github.com/Trivernis/mediarepo-api.git?rev=ddebf4bf0c3e96ede64182ff52bd84f134acc33a#ddebf4bf0c3e96ede64182ff52bd84f134acc33a"
dependencies = [
"async-trait",
"chrono",
"directories",
"mime_guess",
"parking_lot",
"rmp-ipc",
"serde",
@ -1613,6 +1614,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]]
name = "mime_guess"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minisign-verify"
version = "0.1.8"
@ -2507,9 +2524,9 @@ dependencies = [
[[package]]
name = "rmp-ipc"
version = "0.9.0"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6e9a9202fb951b3ca3088a4edd351774ef154efabb759d6aac2911cc1ae60c1"
checksum = "87d2b669d0332e1478b88fdecf4c03cc0c8ce1d977a79eba848f4532213567e6"
dependencies = [
"async-trait",
"byteorder",
@ -3388,6 +3405,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
[[package]]
name = "unicase"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.7"

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

@ -49,6 +49,11 @@ import {FilesTabSidebarComponent} from './pages/home/files-tab/files-tab-sidebar
import {MatExpansionModule} from "@angular/material/expansion";
import {TagItemComponent} from './components/tag-item/tag-item.component';
import { FileEditComponent } from './components/file-edit/file-edit.component';
import { ImportTabComponent } from './pages/home/import-tab/import-tab.component';
import { ImportTabSidebarComponent } from './pages/home/import-tab/import-tab-sidebar/import-tab-sidebar.component';
import { NativeFileSelectComponent } from './components/inputs/native-file-select/native-file-select.component';
import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-sidebar/filesystem-import/filesystem-import.component';
import {MatCheckboxModule} from "@angular/material/checkbox";
@NgModule({
declarations: [
@ -69,6 +74,10 @@ import { FileEditComponent } from './components/file-edit/file-edit.component';
FilesTabSidebarComponent,
TagItemComponent,
FileEditComponent,
ImportTabComponent,
ImportTabSidebarComponent,
NativeFileSelectComponent,
FilesystemImportComponent,
],
imports: [
BrowserModule,
@ -104,6 +113,7 @@ import { FileEditComponent } from './components/file-edit/file-edit.component';
MatTooltipModule,
MatMenuModule,
MatExpansionModule,
MatCheckboxModule,
],
providers: [],
bootstrap: [AppComponent]

@ -16,6 +16,7 @@ import {SafeResourceUrl} from "@angular/platform-browser";
import {Selectable} from "../../models/Selectable";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {CdkDragMove} from "@angular/cdk/drag-drop";
import {TabService} from "../../services/tab/tab.service";
@Component({
selector: 'app-file-gallery',
@ -42,7 +43,8 @@ export class FileGalleryComponent implements OnChanges, OnInit {
public imagePosition = {x: 0, y: 0};
public mouseInImageView = false;
constructor(private fileService: FileService) {
constructor(private tabService: TabService, private fileService: FileService) {
tabService.selectedTab.subscribe(() => this.adjustElementSizes());
}
/**
@ -56,11 +58,19 @@ export class FileGalleryComponent implements OnChanges, OnInit {
this.selectedFile?.unselect();
entry.select();
this.selectedFile = entry;
const selectedIndex = this.entries.indexOf(entry);
//this.virtualScroll.scrollToIndex(, "smooth");
await this.loadSelectedFile();
if (this.virtualScroll) {
this.scrollToSelection();
}
this.fileSelectEvent.emit(this.selectedFile.data);
}
}
private scrollToSelection(): void {
if (this.selectedFile) {
const selectedIndex = this.entries.indexOf(this.selectedFile);
const viewportSize = this.virtualScroll.getViewportSize();
const indexAdjustment = (viewportSize / 260) / 2; // adjustment to have the selected item centered
this.virtualScroll.scrollToIndex(
@ -71,8 +81,6 @@ export class FileGalleryComponent implements OnChanges, OnInit {
this.virtualScroll.measureScrollOffset("left") + 130, "smooth");
}
}
this.fileSelectEvent.emit(this.selectedFile.data);
}
}
/**
@ -192,4 +200,11 @@ export class FileGalleryComponent implements OnChanges, OnInit {
}
return undefined;
}
public adjustElementSizes(): void {
if (this.virtualScroll) {
this.virtualScroll.checkViewportSize();
this.scrollToSelection();
}
}
}

@ -14,6 +14,7 @@ import {File} from "../../models/File";
import {FileGridEntryComponent} from "./file-grid-entry/file-grid-entry.component";
import {GridEntry} from "./file-grid-entry/GridEntry";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../services/tab/tab.service";
@Component({
selector: 'app-file-grid',
@ -38,7 +39,8 @@ export class FileGridComponent implements OnChanges, OnInit {
private ctrlClicked = false;
private gridEntries: GridEntry[] = []
constructor() {
constructor(private tabService: TabService) {
tabService.selectedTab.subscribe(() => this.adjustElementSizes());
}
public ngOnInit(): void {
@ -181,4 +183,10 @@ export class FileGridComponent implements OnChanges, OnInit {
break;
}
}
public adjustElementSizes(): void {
if (this.virtualScroll) {
this.virtualScroll.checkViewportSize();
}
}
}

@ -0,0 +1,17 @@
<div class="file-select-inner" fxLayout="row">
<mat-form-field appearance="fill">
<mat-label>{{label}}</mat-label>
<input #filesInput matInput [value]="files.join(', ')" (change)="this.setFiles(filesInput.value)" class="file-input">
<div class="buttons-native-select">
<button *ngIf="mode === 'files'" (click)="openNativeFileSelectDialog(false)" mat-button>
<mat-icon>insert_drive_file</mat-icon>
</button>
<button *ngIf="mode === 'folders'" (click)="openNativeFileSelectDialog(true)" mat-button>
<mat-icon>folder</mat-icon>
</button>
</div>
</mat-form-field>
</div>

@ -0,0 +1,22 @@
.file-select-inner, mat-form-field {
width: 100%;
}
.file-input {
width: calc(100% - 3em);
float: left;
text-overflow: ellipsis;
}
.buttons-native-select {
position: absolute;
top: 0;
right: 0;
margin-top: -19px;
button {
width: 3em;
height: 3em;
}
}

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

@ -0,0 +1,83 @@
import {
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges
} from '@angular/core';
import {FormControl} from "@angular/forms";
import {dialog} from "@tauri-apps/api";
import {DialogFilter} from "@tauri-apps/api/dialog";
@Component({
selector: 'app-native-file-select',
templateUrl: './native-file-select.component.html',
styleUrls: ['./native-file-select.component.scss']
})
export class NativeFileSelectComponent implements OnInit, OnChanges {
@Input() mode: "files" | "folders" = "files";
@Input() formControlName: string | undefined;
@Input() formControl: FormControl | undefined;
@Input() startPath: string | undefined;
@Input() multiSelect: boolean = true;
@Input() filters: DialogFilter[] = [];
@Output() fileSelect = new EventEmitter<string[]>();
public files: string[] = [];
public label: string | undefined;
constructor() {
}
public ngOnInit(): void {
this.setLabel();
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["mode"]) {
this.setLabel();
}
}
public setFiles(filesExpr: string) {
this.files = filesExpr.split(",");
this.fileSelect.emit(this.files);
}
/**
* Opens the native dialog to select files or folders
* @param {boolean} folders
* @returns {Promise<void>}
*/
public async openNativeFileSelectDialog(folders: boolean) {
const files = await dialog.open({
multiple: this.multiSelect,
directory: folders,
defaultPath: this.startPath,
filters: this.filters,
});
if (files instanceof Array) {
this.files = files;
} else if (files) {
this.files = [files];
}
this.fileSelect.emit(this.files);
}
private setLabel(): void {
switch (this.mode) {
case "files":
this.label = "Select Files";
break;
case "folders":
this.label = "Select a folder";
break;
}
}
}

@ -0,0 +1,8 @@
export class AddFileOptions {
public read_tags_from_txt = true;
public delete_after_import = false;
constructor() {
}
}

@ -0,0 +1,7 @@
export type FileOsMetadata = {
name: string,
path: string,
mime_type: string,
created_at: Date,
modified_at: Date,
}

@ -1,4 +1,4 @@
<mat-drawer-container class="page">
<mat-drawer-container>
<mat-drawer disableClose mode="side" opened>
<app-files-tab-sidebar (searchEndEvent)="this.contentLoading = false"
(searchStartEvent)="this.contentLoading = true"

@ -16,6 +16,7 @@ mat-drawer-content {
mat-drawer-container {
height: 100%;
width: 100%;
overflow: hidden;
}
@ -29,11 +30,6 @@ app-file-gallery, app-files-tab-sidebar {
width: 100%;
}
.page {
height: 100%;
width: 100%;
}
.spinner-overlay {
position: absolute;
top: 0;

@ -1,10 +1,13 @@
<div id="content">
<mat-tab-group #tabGroup>
<mat-tab-group #tabGroup (selectedTabChange)="this.onTabSelectionChange($event)">
<mat-tab label="Repositories">
<app-repositories-tab></app-repositories-tab>
</mat-tab>
<mat-tab *ngIf="this.selectedRepository" label="Files">
<app-files-tab></app-files-tab>
</mat-tab>
<mat-tab *ngIf="this.selectedRepository" label="Import">
<app-import-tab></app-import-tab>
</mat-tab>
</mat-tab-group>
</div>

@ -1,8 +1,9 @@
import {Component, OnInit, ViewChild} from '@angular/core';
import {Repository} from "../../models/Repository";
import {RepositoryService} from "../../services/repository/repository.service";
import {MatTabGroup} from "@angular/material/tabs";
import {MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {TagService} from "../../services/tag/tag.service";
import {TabService} from "../../services/tab/tab.service";
@Component({
selector: 'app-home',
@ -16,8 +17,11 @@ export class HomeComponent implements OnInit {
@ViewChild("tabGroup") tabGroup!: MatTabGroup;
constructor(private repoService: RepositoryService, private tagService: TagService) {
}
constructor(
private tabService: TabService,
private repoService: RepositoryService,
private tagService: TagService)
{}
public async ngOnInit(): Promise<void> {
this.selectedRepository = this.repoService.selectedRepository.getValue();
@ -42,4 +46,8 @@ export class HomeComponent implements OnInit {
async loadRepoData() {
await this.tagService.loadTags();
}
public onTabSelectionChange(event: MatTabChangeEvent): void {
this.tabService.setSelectedTab(event.index);
}
}

@ -0,0 +1,30 @@
<mat-form-field>
<mat-label>Selection Type</mat-label>
<mat-select #selectionType value="files">
<mat-option value="folders">Folders</mat-option>
<mat-option value="files">Files</mat-option>
</mat-select>
</mat-form-field>
<app-native-file-select [mode]="selectionType.value" (fileSelect)="this.setSelectedPaths($event)"></app-native-file-select>
<button mat-flat-button>
{{resolving? "Searching for files..." : this.fileCount + " files found"}}
<mat-progress-bar *ngIf="resolving" mode="indeterminate" color="primary"></mat-progress-bar>
</button>
<mat-divider></mat-divider>
<section class="binary-import-options">
<mat-checkbox [checked]="this.importOptions.read_tags_from_txt" (change)="this.importOptions.read_tags_from_txt = $event.checked">Import tags from
adjacent .txt tag files
</mat-checkbox>
<mat-checkbox [checked]="this.importOptions.delete_after_import" (change)="this.importOptions.delete_after_import = $event.checked" color="warn">
Delete files from original location after import
</mat-checkbox>
</section>
<mat-divider></mat-divider>
<button mat-flat-button color="primary" class="import-button" [disabled]="importing || this.fileCount === 0" (click)="import()">
{{importing? "Importing..." : "Import"}}
</button>
<mat-progress-bar *ngIf="importing" mode="determinate" color="primary" [value]="this.importingProgress"></mat-progress-bar>

@ -0,0 +1,25 @@
app-native-file-select {
width: 100%;
}
button {
width: 100%;
}
mat-divider {
width: 100%;
height: 1px;
margin: 1em 0;
}
.filled-button {
background-color: #5c5c5c;
}
.binary-import-options {
margin-top: 1em;
mat-checkbox {
margin: 0.5em 0;
}
}

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

@ -0,0 +1,63 @@
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 {AddFileOptions} from "../../../../../models/AddFileOptions";
import {File} from "../../../../../models/File";
@Component({
selector: 'app-filesystem-import',
templateUrl: './filesystem-import.component.html',
styleUrls: ['./filesystem-import.component.scss']
})
export class FilesystemImportComponent {
@Output() fileImported = new EventEmitter<File>();
@Output() importFinished = new EventEmitter<void>();
public fileCount: number = 0;
public files: FileOsMetadata[] = [];
public importOptions = new AddFileOptions();
public resolving = false;
public importing = false;
public importingProgress = 0;
constructor(private errorBroker: ErrorBrokerService, private importService: ImportService) {
}
public async setSelectedPaths(paths: string[]) {
this.resolving = true;
try {
this.files = await this.importService.resolvePathsToFiles(paths);
this.fileCount = this.files.length;
} catch (err) {
console.log(err);
this.errorBroker.showError(err);
}
this.resolving = false;
}
public async import() {
this.importing = true;
this.importingProgress = 0;
let count = 0;
for (const file of this.files) {
try {
const resultFile = await this.importService.addLocalFile(file,
this.importOptions);
this.fileImported.emit(resultFile);
} catch (err) {
console.log(err);
this.errorBroker.showError(err);
}
count++;
this.importingProgress = (count / this.fileCount) * 100;
}
this.importing = false;
this.importFinished.emit();
}
}

@ -0,0 +1,20 @@
<div class="import-tab-inner">
<mat-tab-group headerPosition="below">
<mat-tab label="Import">
<div class="import-sidebar-tab-inner" fxLayout="column">
<div class="import-type-select-wrapper" fxFlex="6em">
<mat-form-field class="import-type-select">
<mat-label>Import Type</mat-label>
<mat-select value="filesystem">
<mat-option value="filesystem">Filesystem</mat-option>
</mat-select>
</mat-form-field>
<mat-divider></mat-divider>
</div>
<div class="import-configuration" fxFlex fxFlexFill>
<app-filesystem-import (fileImported)="this.fileImported.emit($event)" (importFinished)="importFinished.emit()"></app-filesystem-import>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>

@ -0,0 +1,34 @@
mat-tab-group, mat-tab {
width: 100%;
height: 100%;
}
.import-tab-inner {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.import-type-select-wrapper {
width: 100%;
.import-type-select {
width: calc(100% - 2em);
height: calc(100% - 2em);
margin: 1em;
mat-select {
height: 100%;
}
}
}
.import-sidebar-tab-inner {
width: 100%;
height: 100%;
}
.import-configuration {
padding: 1em;
}

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

@ -0,0 +1,22 @@
import {
Component,
EventEmitter,
OnInit,
Output,
ViewChild
} from '@angular/core';
import {MatTabGroup} from "@angular/material/tabs";
import {File} from "../../../../models/File";
@Component({
selector: 'app-import-tab-sidebar',
templateUrl: './import-tab-sidebar.component.html',
styleUrls: ['./import-tab-sidebar.component.scss']
})
export class ImportTabSidebarComponent {
@Output() fileImported = new EventEmitter<File>();
@Output() importFinished = new EventEmitter<void>();
constructor() { }
}

@ -0,0 +1,8 @@
<mat-drawer-container autosize>
<mat-drawer disableClose="true" mode="side" opened>
<app-import-tab-sidebar (fileImported)="this.addFileFromImport($event)" (importFinished)="this.refreshFileView()"></app-import-tab-sidebar>
</mat-drawer>
<mat-drawer-content>
<app-file-grid #fileGrid [files]="this.files" ></app-file-grid>
</mat-drawer-content>
</mat-drawer-container>

@ -0,0 +1,27 @@
mat-drawer-container {
height: 100%;
width: 100%;
margin: 0;
overflow: hidden;
}
mat-drawer-content {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
mat-drawer {
height: 100%;
width: 25%;
}
app-import-tab-sidebar, app-file-grid {
height: 100%;
width: 100%;
margin: 0;
}
app-file-grid {
display: block;
}

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

@ -0,0 +1,39 @@
import {Component, ViewChild} from '@angular/core';
import {File} from "../../../models/File";
import {FileService} from "../../../services/file/file.service";
import {FileGridComponent} from "../../../components/file-grid/file-grid.component";
@Component({
selector: 'app-import-tab',
templateUrl: './import-tab.component.html',
styleUrls: ['./import-tab.component.scss']
})
export class ImportTabComponent {
public files: File[] = [];
@ViewChild("fileGrid") fileGrid!: FileGridComponent;
constructor(private fileService: FileService) {
}
/**
* Adds an imported file to the list of imported files
* @param {File} file
* @returns {Promise<void>}
*/
public async addFileFromImport(file: File) {
this.files.push(file);
if (this.files.length % 50 === 0) { // refresh every 50 pictures
this.refreshFileView();
}
}
/**
* Refreshes the file view
* @returns {Promise<void>}
*/
public refreshFileView() {
this.files = [...this.files];
}
}

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ImportService } from './import.service';
describe('ImportService', () => {
let service: ImportService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ImportService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import {FileOsMetadata} from "../../models/FileOsMetadata";
import {invoke} from "@tauri-apps/api/tauri";
import {AddFileOptions} from "../../models/AddFileOptions";
import {File} from "../../models/File";
@Injectable({
providedIn: 'root'
})
export class ImportService {
constructor() { }
/**
* Resolves paths from the local file system into a list of files that can be imported
* @param {string[]} paths
* @returns {Promise<FileOsMetadata[]>}
*/
public async resolvePathsToFiles(paths: string[]): Promise<FileOsMetadata[]> {
return await invoke<FileOsMetadata[]>("plugin:mediarepo|resolve_paths_to_files", {paths});
}
/**
* Imports a file from the local file system
* @param {FileOsMetadata} metadata
* @param {AddFileOptions} options
* @returns {Promise<File>}
*/
public async addLocalFile(metadata: FileOsMetadata, options: AddFileOptions): Promise<File> {
return await invoke<File>("plugin:mediarepo|add_local_file", {metadata, options});
}
}

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { TabService } from './tab.service';
describe('TabService', () => {
let service: TabService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(TabService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import {BehaviorSubject} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class TabService {
public selectedTab = new BehaviorSubject<number>(0);
constructor() { }
public setSelectedTab(index: number) {
this.selectedTab.next(index);
}
}
Loading…
Cancel
Save