RSS Git Download  Clone
Raw Blame History 15kB 431 lines
import { Component, Input, Inject, OnInit, OnDestroy, NgZone, ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatDividerModule } from '@angular/material/divider';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

import { P3xrInputComponent } from '../../components/p3xr-input.component';
import { I18nService } from '../../services/i18n.service';
import { CommonService } from '../../services/common.service';
import { MainCommandService } from '../../services/main-command.service';
import { SocketService } from '../../services/socket.service';
import { TreecontrolSettingsDialogService } from '../../dialogs/treecontrol-settings-dialog.service';
import { KeyImportDialogService } from '../../dialogs/key-import-dialog.service';
import { RedisStateService } from '../../services/redis-state.service';
import { SettingsService } from '../../services/settings.service';
import { OverlayService } from '../../services/overlay.service';

@Component({
    selector: 'p3xr-database-treecontrol-controls',
    standalone: true,
    imports: [
        CommonModule,
        FormsModule,
        MatTooltipModule,
        MatMenuModule,
        MatIconModule,
        MatDividerModule,
        P3xrInputComponent,
    ],
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './database-treecontrol-controls.component.html',
    styleUrls: ['./database-treecontrol-controls.component.scss'],
    encapsulation: ViewEncapsulation.None,
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DatabaseTreecontrolControlsComponent implements OnInit, OnDestroy {
    @Input() p3xrMainRef: any;

    page = 1;
    pages = 0;
    search = '';
    keyCount = 0;
    redisTreeDivider = ':';
    treeDividers: string[] = [];
    searchClientSide = false;
    isReadonly = false;

    readonly strings;

    private readonly unsubs: Array<() => void> = [];
    private readonly dividerChange$ = new Subject<string>();

    constructor(
        @Inject(NgZone) private readonly ngZone: NgZone,
        @Inject(ElementRef) private readonly elementRef: ElementRef,
        @Inject(I18nService) private readonly i18n: I18nService,
        @Inject(CommonService) private readonly common: CommonService,
        @Inject(MainCommandService) private readonly cmd: MainCommandService,
        @Inject(TreecontrolSettingsDialogService) private readonly treeSettingsDialog: TreecontrolSettingsDialogService,
        @Inject(KeyImportDialogService) private readonly keyImportDialog: KeyImportDialogService,
        @Inject(SocketService) private readonly socket: SocketService,
        @Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef,
        @Inject(RedisStateService) private readonly state: RedisStateService,
        @Inject(SettingsService) private readonly settings: SettingsService,
        @Inject(OverlayService) private readonly overlay: OverlayService,
    ) {
        this.strings = this.i18n.strings;
    }

    ngOnInit(): void {
        this.syncFromGlobal();

        // If search was restored from state (e.g. cookie), trigger it
        if (this.search) {
            this.onSearchChange();
        }

        const sub = this.dividerChange$.pipe(debounceTime(666)).subscribe((value) => {
            this.applyDivider(value);
        });
        this.unsubs.push(() => sub.unsubscribe());

        const refreshSub = this.cmd.treeRefresh$.subscribe(() => {
            this.ngZone.run(() => {
                this.syncFromGlobal();
                this.requestViewRefresh();
            });
        });
        this.unsubs.push(() => refreshSub.unsubscribe());
    }

    ngOnDestroy(): void {
        this.unsubs.forEach((unsub) => unsub());
    }

    keyCountText(): string {
        const fn = this.strings()?.status?.keyCount;
        return typeof fn === 'function' ? fn({ keyCount: this.keyCount }) : String(this.keyCount);
    }

    searchPlaceholder(): string {
        const searchStrings = this.strings()?.page?.treeControls?.search;
        return this.searchClientSide
            ? (searchStrings?.placeholderClient)
            : (searchStrings?.placeholderServer);
    }

    treeExpandAll(): void {
        this.common.treeExpandAll$.next();
    }

    treeExpandToLevel(level: number): void {
        this.common.treeExpandToLevel$.next(level);
    }

    treeCollapseAll(): void {
        this.common.treeCollapseAll$.next();
    }

    async refreshTree(): Promise<void> {
        await this.cmd.refresh();
        this.ngZone.run(() => {
            this.syncFromGlobal();
            this.requestViewRefresh();
        });
    }

    async openTreeSettingDialog(event: Event): Promise<void> {
        await this.treeSettingsDialog.show({ $event: event });
        this.syncFromGlobal();
        this.requestViewRefresh();
    }

    onDividerInputChange(value: string): void {
        this.redisTreeDivider = value ?? '';
        this.settings.redisTreeDivider.set(this.redisTreeDivider);
        this.dividerChange$.next(this.redisTreeDivider);
    }

    setDivider(value: string): void {
        this.redisTreeDivider = value ?? '';
        this.applyDivider(this.redisTreeDivider);
    }

    private applyDivider(value: string): void {
        this.settings.redisTreeDivider.set(value);

        this.state.redisChanged.set(true);
        this.cmd.treeRefresh$.next();

        this.syncFromGlobal();
    }

    pageAction(page: 'first' | 'prev' | 'next' | 'last'): void {
        const currentPage = this.state.page() ?? 1;
        const totalPages = this.pages;

        switch (page) {
            case 'prev':
                if (currentPage - 1 >= 1) {
                    this.state.page.set(currentPage - 1);
                }
                break;
            case 'next':
                if (currentPage + 1 <= totalPages) {
                    this.state.page.set(currentPage + 1);
                }
                break;
            case 'last': {
                this.state.page.set(totalPages);
                break;
            }
            case 'first': {
                this.state.page.set(1);
                break;
            }
        }

        this.syncFromGlobal();
    }

    onPageInputChange(value: any): void {
        const parsed = parseInt(value, 10);
        const newPage = isNaN(parsed) ? 1 : parsed;
        this.state.page.set(newPage);
        this.pageChange();
    }

    pageChange(): void {
        let currentPage = this.state.page() ?? 1;
        const totalPages = this.pages;
        if (currentPage < 1) {
            currentPage = 1;
        } else if (currentPage > totalPages) {
            currentPage = totalPages;
        }
        this.state.page.set(currentPage);
        this.syncFromGlobal();
    }

    onSearchModelChange(value: string): void {
        this.search = value ?? '';
        this.state.search.set(this.search);
    }

    async onSearchChange(): Promise<void> {
        this.state.search.set(this.search);

        this.state.page.set(1);

        if (this.settings.searchClientSide()) {
            this.state.redisChanged.set(true);
        }

        await this.cmd.refresh();
        this.syncFromGlobal();
        this.requestViewRefresh();
        this.socket.tick();
    }

    async clearSearch(): Promise<void> {
        this.search = '';
        await this.onSearchChange();
    }

    async exportKeys(): Promise<void> {
        const keys = this.state.keysRaw();
        if (!Array.isArray(keys) || keys.length === 0) {
            this.common.toast({ message: this.strings().label?.noKeysToExport });
            return;
        }

        try {
            this.overlay.show({
                message: this.strings().label?.exportProgress,
            });

            const response = await this.socket.request({
                action: 'key/export',
                payload: { keys },
            });

            const json = JSON.stringify(response.data, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            const connName = this.state.connection()?.name || 'redis';
            const db = this.state.currentDatabase() ?? 0;
            a.download = `${connName}-db${db}-export.json`;
            a.click();
            URL.revokeObjectURL(url);

            this.common.toast({ message: this.strings().status?.exportDone });
        } catch (e) {
            this.common.generalHandleError(e);
        } finally {
            this.overlay.hide();
        }
    }

    async importKeys(): Promise<void> {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        input.onchange = async () => {
            const file = input.files?.[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onerror = () => this.common.generalHandleError(reader.error);
            reader.onload = async (e: any) => {
                try {
                    const parsed = JSON.parse(e.target.result);
                    if (!parsed?.keys || !Array.isArray(parsed.keys) || parsed.keys.length === 0) {
                        this.common.toast({ message: this.strings().label?.importNoKeys });
                        return;
                    }

                    const result = await this.keyImportDialog.show({ data: parsed });
                    if (result?.pending) {
                        // Dialog closed, now show overlay and do import
                        try {
                            this.overlay.show({
                                message: this.strings().label?.importProgress,
                            });
                            const response = await this.socket.request({
                                action: 'key/import',
                                payload: {
                                    keys: result.keys,
                                    conflictMode: result.conflictMode,
                                },
                            });
                            const data = response.data;
                            const statusFn = this.strings().status?.importDone;
                            const message = typeof statusFn === 'function'
                                ? statusFn(data)
                                : `Import complete: ${data.created} created, ${data.skipped} skipped, ${data.errors} errors`;
                            this.common.toast({ message });
                        } finally {
                            this.overlay.hide();
                        }
                        // Refresh tree after import
                        await this.cmd.refresh();
                        this.ngZone.run(() => {
                            this.syncFromGlobal();
                            this.requestViewRefresh();
                        });
                    }
                } catch (e: any) {
                    if (e !== undefined && e !== null) {
                        this.common.generalHandleError(e);
                    }
                }
            };
            reader.readAsText(file);
        };
        input.click();
    }

    deleteSearchLabel(): string {
        const strings = this.strings();
        if (this.search.length > 0) {
            const fn = strings.intention?.deleteSearchKeys;
            return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Delete ${this.keyCount} matching keys`;
        }
        const fn = strings.intention?.deleteAllKeysMenu;
        return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Delete all ${this.keyCount} keys`;
    }

    async deleteSearchKeys(): Promise<void> {
        let match: string;
        if (this.search.length > 0) {
            if (this.settings.searchStartsWith()) {
                match = this.search + '*';
            } else {
                match = '*' + this.search + '*';
            }
        } else {
            match = '*';
        }

        try {
            const confirmFn = this.strings().confirm?.deleteSearchKeys;
            const confirmMsg = typeof confirmFn === 'function'
                ? confirmFn({ count: this.keyCount, pattern: match })
                : `Are you sure to delete all keys matching "${match}"? Found ${this.keyCount} keys.`;

            await this.common.confirm({ message: confirmMsg });

            this.overlay.show({
                message: this.strings().label?.deletingSearchKeys,
            });

            const response = await this.socket.request({
                action: 'key/delete-search-keys',
                payload: { match },
            });

            const deletedCount = response.deletedCount || 0;
            const statusFn = this.strings().status?.deletedSearchKeys;
            const message = typeof statusFn === 'function'
                ? statusFn({ count: deletedCount })
                : `Deleted ${deletedCount} keys`;
            this.common.toast({ message });

            await this.cmd.refresh();
            this.ngZone.run(() => {
                this.syncFromGlobal();
                this.requestViewRefresh();
            });
        } catch (e: any) {
            if (e !== undefined && e !== null) {
                this.common.generalHandleError(e);
            }
        } finally {
            this.overlay.hide();
        }
    }

    searchInputClass(): string {
        const hasSearch = this.search.length > 0;
        if (this.isReadonly) {
            return hasSearch ? 'search-readonly-clear' : 'search-readonly';
        }
        return hasSearch ? 'search-full-clear' : 'search-full';
    }

    exportLabel(): string {
        const strings = this.strings();
        if (this.search.length > 0) {
            const fn = strings.intention?.exportSearchResults;
            return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Export ${this.keyCount} results`;
        }
        const fn = strings.intention?.exportAllKeys;
        return typeof fn === 'function' ? fn({ count: this.keyCount }) : `Export all ${this.keyCount} keys`;
    }

    addRootKey(event: Event): void {
        this.cmd.addKey({ event });
    }

    private syncFromGlobal(): void {
        // Access state.filteredKeys() to trigger the computed getter
        const _keys = this.state.filteredKeys();
        this.page = Number(this.state.page() ?? 1);
        this.pages = Number(this.state.pages() ?? 0);
        this.search = this.state.search() ?? '';
        const keysRaw = this.state.keysRaw();
        this.keyCount = Array.isArray(keysRaw) ? keysRaw.length : 0;
        this.redisTreeDivider = this.settings.redisTreeDivider() ?? ':';
        this.treeDividers = Array.isArray(this.state.cfg()?.treeDividers) ? this.state.cfg().treeDividers.slice() : [];
        this.searchClientSide = !!this.settings.searchClientSide();
        this.isReadonly = this.state.connection()?.readonly === true;
    }

    private requestViewRefresh(): void {
        setTimeout(() => {
            try {
                this.cdr.detectChanges();
            } catch {
                // Ignore late refreshes during teardown.
            }
        });
    }
}