commit
10077d53b0
@ -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,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>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
this.contextMenu.onContextMenu(event);
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes["files"]) {
|
||||
this.applyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
public async copyFileHash(): Promise<void> {
|
||||
await clipboard.writeText(this.file.hash);
|
||||
public onContextMenu(event: MouseEvent, files: File[]) {
|
||||
this.files = files;
|
||||
this.applyStatus();
|
||||
this.contextMenu.onContextMenu(event);
|
||||
}
|
||||
|
||||
public async exportFile(): Promise<void> {
|
||||
const path = await FileHelper.getFileDownloadLocation(this.file)
|
||||
public async deleteFilesPermanently() {
|
||||
const deleted = await this.deletePermanently(this.files);
|
||||
|
||||
if (path) {
|
||||
try {
|
||||
await this.fileService.saveFile(this.file, path);
|
||||
} catch (err) {
|
||||
this.errorBroker.showError(err);
|
||||
if (deleted) {
|
||||
this.fileDeleted.emit(this.files);
|
||||
}
|
||||
}
|
||||
|
||||
private applyStatus() {
|
||||
this.actionDeletePermantently = true;
|
||||
this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false;
|
||||
|
||||
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,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>
|
||||
|
@ -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,3 @@
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
@ -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,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,177 +1,143 @@
|
||||
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 {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
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);
|
||||
public entrySelect(index: number, subindex: number = -1): void {
|
||||
this.selectedIndices[index] = this.selectedIndices[index] ?? [];
|
||||
this.selectedIndices[index].push(subindex);
|
||||
}
|
||||
this.unselectAll();
|
||||
|
||||
public entryUnselect(index: number, subindex: number = -1): void {
|
||||
this.selectedIndices[index] = this.selectedIndices[index] ?? [];
|
||||
removeByValue(this.selectedIndices[index], subindex);
|
||||
}
|
||||
|
||||
public addFilter(tag: string) {
|
||||
const query = TagQuery.fromString(tag);
|
||||
public addFilter(expression: FilterExpression): void {
|
||||
this.filters.addFilterExpression(expression);
|
||||
this.buildRenderedEntries();
|
||||
}
|
||||
|
||||
if (this.mode === "AND" || this.filters.length === 0) {
|
||||
this.filters.push(
|
||||
new Selectable<FilterExpression>(
|
||||
new SingleFilterExpression(query),
|
||||
false));
|
||||
tag = tag.replace(/^-/g, "");
|
||||
public removeSelectedFilters(): void {
|
||||
const orderedIndices = Object.keys(this.selectedIndices).map(k => Number(k)).sort().reverse();
|
||||
|
||||
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() ?? [];
|
||||
for (const indexStr of orderedIndices) {
|
||||
const index = indexStr;
|
||||
const subIndices: number[] = this.selectedIndices[index];
|
||||
|
||||
queryList.push(query);
|
||||
const filterExpression = new OrFilterExpression(queryList);
|
||||
filterExpression.removeDuplicates();
|
||||
this.filters.push(
|
||||
new Selectable<FilterExpression>(filterExpression,
|
||||
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.unselectAll();
|
||||
}
|
||||
|
||||
public addToSelection(query: TagQuery): void {
|
||||
this.selectedQueries.push(query);
|
||||
}
|
||||
this.selectedIndices = {};
|
||||
this.buildRenderedEntries();
|
||||
}
|
||||
|
||||
public removeFromSelection(query: TagQuery): void {
|
||||
const index = this.selectedQueries.indexOf(query);
|
||||
if (index > 0) {
|
||||
this.selectedQueries.splice(index, 1);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
public unselectAll() {
|
||||
this.filters.forEach(filter => filter.selected = false);
|
||||
this.selectedQueries = [];
|
||||
this.filterListItems.forEach(i => i.selectedIndices = []);
|
||||
}
|
||||
|
||||
public convertSelectionToAndExpression(): void {
|
||||
for (const query of this.selectedQueries) {
|
||||
this.filters.push(
|
||||
new Selectable<FilterExpression>(
|
||||
new SingleFilterExpression(query),
|
||||
false));
|
||||
}
|
||||
this.removeFilterDuplicates();
|
||||
this.unselectAll();
|
||||
if (deleteOriginal) {
|
||||
this.removeSelectedFilters();
|
||||
} else {
|
||||
this.selectedIndices = {};
|
||||
}
|
||||
|
||||
public convertSelectionToOrExpression(): void {
|
||||
const queries = this.selectedQueries;
|
||||
const expression = new OrFilterExpression(queries);
|
||||
this.filters.push(new Selectable<FilterExpression>(expression, false));
|
||||
this.removeFilterDuplicates();
|
||||
this.unselectAll();
|
||||
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 (subindices.length === 1 && subindices[0] === -1) {
|
||||
const filterEntry = this.filters.getFilters()[index];
|
||||
if ("Query" in filterEntry) {
|
||||
queries.push(filterEntry.Query);
|
||||
}
|
||||
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]);
|
||||
} 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] });
|
||||
}
|
||||
|
||||
@HostListener("window:keyup", ["$event"])
|
||||
private async handleKeyupEvent(event: KeyboardEvent) {
|
||||
if (event.key === "Shift") {
|
||||
this.mode = "AND";
|
||||
this.buildRenderedEntries();
|
||||
}
|
||||
|
||||
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 </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>
|
@ -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…
Reference in New Issue