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>
|
<mat-drawer disableClose mode="side" opened>
|
||||||
<app-files-tab-sidebar [state]="this.state" (searchEndEvent)="this.contentLoading = false;"
|
<app-files-tab-sidebar (searchEndEvent)="this.contentLoading = false;" (searchStartEvent)="this.contentLoading = true;"
|
||||||
(searchStartEvent)="this.contentLoading = true;"
|
[selectedFiles]="this.selectedFiles"
|
||||||
[selectedFiles]="this.selectedFiles"></app-files-tab-sidebar>
|
[state]="this.state"></app-files-tab-sidebar>
|
||||||
</mat-drawer>
|
</mat-drawer>
|
||||||
<mat-drawer-content>
|
<mat-drawer-content>
|
||||||
<app-busy-indicator [blurBackground]="true" [busy]="contentLoading">
|
<app-busy-indicator [blurBackground]="true" [busy]="contentLoading" [darkenBackground]="true">
|
||||||
<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-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>
|
</app-busy-indicator>
|
||||||
</mat-drawer-content>
|
</mat-drawer-content>
|
||||||
</mat-drawer-container>
|
</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>
|
<app-context-menu #contextMenu>
|
||||||
<ng-content select="[content-before]"></ng-content>
|
<ng-content select="[content-before]"></ng-content>
|
||||||
<button (click)="this.copyFileHash()" mat-menu-item>Copy Hash</button>
|
<ng-container *ngIf="this.files">
|
||||||
<button (click)="this.exportFile()" mat-menu-item>Save As...</button>
|
<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>
|
<ng-content></ng-content>
|
||||||
</app-context-menu>
|
</app-context-menu>
|
||||||
|
@ -1,47 +1,71 @@
|
|||||||
import {Component, ViewChild} from "@angular/core";
|
import {Component, EventEmitter, OnChanges, Output, SimpleChanges, ViewChild} from "@angular/core";
|
||||||
import {File} from "../../../../models/File";
|
import {File} from "../../../../../api/models/File";
|
||||||
import {
|
import {ContextMenuComponent} from "../../app-common/context-menu/context-menu.component";
|
||||||
ContextMenuComponent
|
|
||||||
} from "../../app-common/context-menu/context-menu.component";
|
|
||||||
import {clipboard} from "@tauri-apps/api";
|
|
||||||
import {FileService} from "../../../../services/file/file.service";
|
import {FileService} from "../../../../services/file/file.service";
|
||||||
import {
|
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
|
||||||
ErrorBrokerService
|
import {MatDialog, MatDialogRef} from "@angular/material/dialog";
|
||||||
} from "../../../../services/error-broker/error-broker.service";
|
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
|
||||||
import {FileHelper} from "../../../../services/file/file.helper";
|
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({
|
@Component({
|
||||||
selector: "app-file-context-menu",
|
selector: "app-file-context-menu",
|
||||||
templateUrl: "./file-context-menu.component.html",
|
templateUrl: "./file-context-menu.component.html",
|
||||||
styleUrls: ["./file-context-menu.component.scss"]
|
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;
|
@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) {
|
public ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.file = file;
|
if (changes["files"]) {
|
||||||
this.contextMenu.onContextMenu(event);
|
this.applyStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async copyFileHash(): Promise<void> {
|
public onContextMenu(event: MouseEvent, files: File[]) {
|
||||||
await clipboard.writeText(this.file.hash);
|
this.files = files;
|
||||||
|
this.applyStatus();
|
||||||
|
this.contextMenu.onContextMenu(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exportFile(): Promise<void> {
|
public async deleteFilesPermanently() {
|
||||||
const path = await FileHelper.getFileDownloadLocation(this.file)
|
const deleted = await this.deletePermanently(this.files);
|
||||||
|
|
||||||
if (path) {
|
if (deleted) {
|
||||||
try {
|
this.fileDeleted.emit(this.files);
|
||||||
await this.fileService.saveFile(this.file, path);
|
}
|
||||||
} catch (err) {
|
|
||||||
this.errorBroker.showError(err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'"
|
*ngIf="this.mode === 'grid'"
|
||||||
[files]="this.files" [preselectedFile]="this.preselectedFile"></app-file-grid>
|
[files]="this.files"
|
||||||
<app-file-gallery #fileGallery (closeEvent)="this.setMode('grid')" (fileSelectEvent)="this.onSingleFileSelect($event)"
|
[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'"
|
*ngIf="this.mode === 'gallery'"
|
||||||
[files]="this.files"
|
[files]="this.files"
|
||||||
[preselectedFile]="this.preselectedFile"></app-file-gallery>
|
[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 {
|
||||||
import {File} from "../../../../models/File";
|
Component,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnInit,
|
||||||
|
SimpleChanges
|
||||||
|
} from "@angular/core";
|
||||||
|
import {File} from "../../../../../api/models/File";
|
||||||
import {FileService} from "../../../../services/file/file.service";
|
import {FileService} from "../../../../services/file/file.service";
|
||||||
|
import {FileMetadata} from "../../../../../api/api-types/files";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-file-metadata",
|
selector: "app-file-metadata",
|
||||||
templateUrl: "./file-metadata.component.html",
|
templateUrl: "./file-metadata.component.html",
|
||||||
styleUrls: ["./file-metadata.component.scss"]
|
styleUrls: ["./file-metadata.component.scss"]
|
||||||
})
|
})
|
||||||
export class FileMetadataComponent {
|
export class FileMetadataComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
@Input() file!: File;
|
@Input() file!: File;
|
||||||
|
public fileMetadata: FileMetadata | undefined;
|
||||||
|
public loading = false;
|
||||||
|
|
||||||
constructor(private fileService: FileService) {
|
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) {
|
public async saveFileName(name: string) {
|
||||||
const newFile = await this.fileService.updateFileName(this.file, name);
|
this.loading = true;
|
||||||
this.file.name = newFile.name;
|
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 {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||||
import {SortDialogComponent} from "../sort-dialog/sort-dialog.component";
|
import {SortDialogComponent} from "../sort-dialog/sort-dialog.component";
|
||||||
import {
|
import {Tag} from "../../../../../../api/models/Tag";
|
||||||
FilterExpression,
|
import {SearchFilters} from "../../../../../../api/models/SearchFilters";
|
||||||
OrFilterExpression,
|
import {FilterExpression, FilterQuery} from "../../../../../../api/api-types/files";
|
||||||
SingleFilterExpression
|
import {enumerate, removeByValue} from "../../../../../utils/list-utils";
|
||||||
} from "../../../../../models/FilterExpression";
|
|
||||||
import {TagQuery} from "../../../../../models/TagQuery";
|
type IndexableSelection<T> = {
|
||||||
import {Tag} from "../../../../../models/Tag";
|
[key: number]: T
|
||||||
import {
|
};
|
||||||
TagFilterListItemComponent
|
|
||||||
} from "./tag-filter-list-item/tag-filter-list-item.component";
|
|
||||||
import {Selectable} from "../../../../../models/Selectable";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-filter-dialog",
|
selector: "app-filter-dialog",
|
||||||
templateUrl: "./filter-dialog.component.html",
|
templateUrl: "./filter-dialog.component.html",
|
||||||
styleUrls: ["./filter-dialog.component.scss"]
|
styleUrls: ["./filter-dialog.component.scss"]
|
||||||
})
|
})
|
||||||
export class FilterDialogComponent {
|
export class FilterDialogComponent implements OnChanges {
|
||||||
|
|
||||||
public filters: Selectable<FilterExpression>[];
|
|
||||||
public availableTags: Tag[] = [];
|
public availableTags: Tag[] = [];
|
||||||
public mode: "AND" | "OR" = "AND";
|
public filters = new SearchFilters([]);
|
||||||
|
public renderedFilterEntries: [number, FilterExpression][] = [];
|
||||||
@ViewChildren(
|
private selectedIndices: IndexableSelection<number[]> = {};
|
||||||
TagFilterListItemComponent) filterListItems!: TagFilterListItemComponent[];
|
|
||||||
|
|
||||||
private selectedQueries: TagQuery[] = [];
|
|
||||||
|
|
||||||
constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject(
|
constructor(public dialogRef: MatDialogRef<SortDialogComponent>, @Inject(
|
||||||
MAT_DIALOG_DATA) data: any) {
|
MAT_DIALOG_DATA) data: any) {
|
||||||
this.filters = data.filterEntries.map(
|
|
||||||
(f: FilterExpression) => new Selectable<FilterExpression>(f,
|
|
||||||
false)) ?? [];
|
|
||||||
this.availableTags = data.availableTags ?? [];
|
this.availableTags = data.availableTags ?? [];
|
||||||
|
this.filters = data.filters;
|
||||||
|
this.buildRenderedEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static checkFiltersEqual(l: FilterExpression, r: FilterExpression): boolean {
|
public ngOnChanges(changes: SimpleChanges): void {
|
||||||
const lTags = l.queryList().map(q => q.getNormalizedTag()).sort();
|
if (changes["filters"]) {
|
||||||
const rTags = r.queryList().map(q => q.getNormalizedTag()).sort();
|
this.buildRenderedEntries();
|
||||||
let match = false;
|
|
||||||
|
|
||||||
if (lTags.length == rTags.length) {
|
|
||||||
match = true;
|
|
||||||
|
|
||||||
for (const tag of lTags) {
|
|
||||||
match = rTags.includes(tag);
|
|
||||||
if (!match) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
public cancelFilter(): void {
|
public cancelFilter(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public confirmFilter(): void {
|
public confirmFilter(): void {
|
||||||
this.dialogRef.close(this.filters.map(f => f.data));
|
this.dialogRef.close(this.filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeFilter(event: TagFilterListItemComponent): void {
|
public entrySelect(index: number, subindex: number = -1): void {
|
||||||
const filter = event.expression;
|
this.selectedIndices[index] = this.selectedIndices[index] ?? [];
|
||||||
const index = this.filters.findIndex(f => f === filter);
|
this.selectedIndices[index].push(subindex);
|
||||||
if (index >= 0) {
|
|
||||||
this.filters.splice(index, 1);
|
|
||||||
}
|
}
|
||||||
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) {
|
public addFilter(expression: FilterExpression): void {
|
||||||
const query = TagQuery.fromString(tag);
|
this.filters.addFilterExpression(expression);
|
||||||
|
this.buildRenderedEntries();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.mode === "AND" || this.filters.length === 0) {
|
public removeSelectedFilters(): void {
|
||||||
this.filters.push(
|
const orderedIndices = Object.keys(this.selectedIndices).map(k => Number(k)).sort().reverse();
|
||||||
new Selectable<FilterExpression>(
|
|
||||||
new SingleFilterExpression(query),
|
|
||||||
false));
|
|
||||||
tag = tag.replace(/^-/g, "");
|
|
||||||
|
|
||||||
if (this.filters.filter(t => t.data.partiallyEq(tag)).length > 1) {
|
for (const indexStr of orderedIndices) {
|
||||||
const index = this.filters.findIndex(
|
const index = indexStr;
|
||||||
t => t.data.partiallyEq(tag));
|
const subIndices: number[] = this.selectedIndices[index];
|
||||||
this.filters.splice(index, 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let queryList = this.filters.pop()?.data.queryList() ?? [];
|
|
||||||
|
|
||||||
queryList.push(query);
|
if (subIndices.length === 1 && subIndices[0] === -1) {
|
||||||
const filterExpression = new OrFilterExpression(queryList);
|
this.filters.removeFilterAtIndex(index);
|
||||||
filterExpression.removeDuplicates();
|
} else if (subIndices.length > 0) {
|
||||||
this.filters.push(
|
for (const subIndex of subIndices.sort().reverse()) { // need to remove from the top down to avoid index shifting
|
||||||
new Selectable<FilterExpression>(filterExpression,
|
this.filters.removeSubfilterAtIndex(index, subIndex);
|
||||||
false));
|
|
||||||
}
|
}
|
||||||
this.unselectAll();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public addToSelection(query: TagQuery): void {
|
this.selectedIndices = {};
|
||||||
this.selectedQueries.push(query);
|
this.buildRenderedEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeFromSelection(query: TagQuery): void {
|
public createAndFromSelection(deleteOriginal: boolean): void {
|
||||||
const index = this.selectedQueries.indexOf(query);
|
const expressions: FilterExpression[] = [];
|
||||||
if (index > 0) {
|
|
||||||
this.selectedQueries.splice(index, 1);
|
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();
|
if (deleteOriginal) {
|
||||||
this.unselectAll();
|
this.removeSelectedFilters();
|
||||||
|
} else {
|
||||||
|
this.selectedIndices = {};
|
||||||
}
|
}
|
||||||
|
expressions.forEach(e => this.filters.addFilterExpression(e));
|
||||||
public convertSelectionToOrExpression(): void {
|
this.buildRenderedEntries();
|
||||||
const queries = this.selectedQueries;
|
|
||||||
const expression = new OrFilterExpression(queries);
|
|
||||||
this.filters.push(new Selectable<FilterExpression>(expression, false));
|
|
||||||
this.removeFilterDuplicates();
|
|
||||||
this.unselectAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public invertSelection(): void {
|
public createOrFromSelection(deleteOriginal: boolean): void {
|
||||||
this.selectedQueries.forEach(query => query.negate = !query.negate);
|
const queries: FilterQuery[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
private removeFilterDuplicates() {
|
for (const indexStr in this.selectedIndices) {
|
||||||
const filters = this.filters;
|
const index = Number(indexStr);
|
||||||
let newFilters: Selectable<FilterExpression>[] = [];
|
const subindices = this.selectedIndices[index];
|
||||||
|
|
||||||
for (const filterItem of filters) {
|
if (subindices.length === 1 && subindices[0] === -1) {
|
||||||
if (filterItem.data.filter_type == "OrExpression") {
|
const filterEntry = this.filters.getFilters()[index];
|
||||||
(filterItem.data as OrFilterExpression).removeDuplicates();
|
if ("Query" in filterEntry) {
|
||||||
|
queries.push(filterEntry.Query);
|
||||||
}
|
}
|
||||||
if (newFilters.findIndex(
|
} else {
|
||||||
f => FilterDialogComponent.checkFiltersEqual(f.data,
|
for (const subIndex of subindices) {
|
||||||
filterItem.data)) < 0) {
|
const query = this.filters.getSubfilterAtIndex(index, subIndex);
|
||||||
if (filterItem.data.filter_type == "OrExpression" && filterItem.data.queryList().length === 1) {
|
if (query) {
|
||||||
filterItem.data = new SingleFilterExpression(
|
queries.push(query);
|
||||||
filterItem.data.queryList()[0]);
|
|
||||||
}
|
}
|
||||||
newFilters.push(filterItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.filters = newFilters;
|
|
||||||
}
|
}
|
||||||
|
if (deleteOriginal) {
|
||||||
@HostListener("window:keydown", ["$event"])
|
this.removeSelectedFilters();
|
||||||
private async handleKeydownEvent(event: KeyboardEvent) {
|
} else {
|
||||||
if (event.key === "Shift") {
|
this.selectedIndices = {};
|
||||||
this.mode = "OR";
|
|
||||||
}
|
}
|
||||||
|
if (queries.length > 1) {
|
||||||
|
this.filters.addFilterExpression({ OrExpression: queries });
|
||||||
|
} else if (queries.length === 1) {
|
||||||
|
this.filters.addFilterExpression({ Query: queries[0] });
|
||||||
}
|
}
|
||||||
|
this.buildRenderedEntries();
|
||||||
@HostListener("window:keyup", ["$event"])
|
|
||||||
private async handleKeyupEvent(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Shift") {
|
|
||||||
this.mode = "AND";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildRenderedEntries() {
|
||||||
|
this.renderedFilterEntries = enumerate(this.filters.getFilters());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
<span *ngIf="this.orExpression" class="or-expression">
|
||||||
|
<mat-list>
|
||||||
|
<mat-list-item *ngFor="let entry of this.orExpression"
|
||||||
|
[class.selected]="componentSelectable.selected"
|
||||||
|
class="or-filter-list-item">
|
||||||
|
<span *ngIf="entry[0] > 0" class="or-combinator">OR </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