RSS Git Download  Clone
Raw Blame History 32kB 613 lines
import { Component, Inject, OnInit, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component';
import { BreakpointObserver } from '@angular/cdk/layout';
import { I18nService } from '../services/i18n.service';
import { CommonService } from '../services/common.service';
import { SocketService } from '../services/socket.service';
import { RedisStateService } from '../services/redis-state.service';
import { SettingsService } from '../services/settings.service';
import { JsonViewDialogService } from './json-view-dialog.service';
import { JsonEditorDialogService } from './json-editor-dialog.service';
import { OverlayService } from '../services/overlay.service';
import { DiffDialogService } from './diff-dialog.service';

export interface KeyNewOrSetDialogData {
    type: 'add' | 'edit' | 'append';
    $event?: any;
    node?: any;
    model?: any;
}

/**
 * Key New/Edit dialog — Angular replacement for p3xrDialogKeyNewOrSet.
 * Multi-type form for creating or editing Redis keys (string, list, hash, set, zset, stream).
 */
@Component({
    selector: 'p3xr-key-new-or-set-dialog',
    standalone: true,
    imports: [
        CommonModule, FormsModule,
        MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule,
        MatSlideToggleModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule,
        DialogCancelButtonComponent,
    ],
    template: `
        <form (ngSubmit)="submit()" novalidate #keyForm="ngForm">
            <mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong">
                <span mat-dialog-title class="p3xr-dialog-title">
                    {{ getTitle() }}
                </span>
                <button mat-icon-button type="button" (click)="cancel()">
                    <mat-icon>close</mat-icon>
                </button>
            </mat-toolbar>

            <mat-dialog-content class="p3xr-dialog-content">
                <mat-form-field class="full-width">
                    <mat-label>{{ strings().form?.key?.field?.key }}</mat-label>
                    <input matInput required minlength="1" name="key" [(ngModel)]="model.key"
                           [disabled]="options.type !== 'add'" />
                    @if (keyForm.controls['key']?.invalid && keyForm.controls['key']?.touched) {
                        <mat-error>{{ strings().form?.key?.error?.key }}</mat-error>
                    }
                </mat-form-field>

                <mat-form-field class="full-width">
                    <mat-label>{{ strings().form?.key?.field?.type }}</mat-label>
                    <mat-select name="type" [(ngModel)]="model.type" [disabled]="options.type !== 'add'">
                        @for (t of types; track t) {
                            <mat-option [value]="t">{{ strings().redisTypes?.[t] || t }}</mat-option>
                        }
                    </mat-select>
                </mat-form-field>

                <!-- Type-specific fields -->
                @switch (model.type) {
                    @case ('list') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.index }}</mat-label>
                            <input matInput type="number" step="1" name="index" [(ngModel)]="model.index" />
                        </mat-form-field>
                        <div class="info-text">{{ strings().label?.redisListIndexInfo }}</div>
                    }
                    @case ('hash') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.hashKey }}</mat-label>
                            <input matInput required minlength="1" name="hashKey" [(ngModel)]="model.hashKey" />
                            @if (keyForm.controls['hashKey']?.invalid && keyForm.controls['hashKey']?.touched) {
                                <mat-error>{{ strings().form?.key?.error?.hashKey }}</mat-error>
                            }
                        </mat-form-field>
                    }
                    @case ('zset') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.score }}</mat-label>
                            <input matInput type="number" required name="score" [(ngModel)]="model.score" />
                            @if (keyForm.controls['score']?.invalid && keyForm.controls['score']?.touched) {
                                <mat-error>{{ strings().form?.key?.error?.score }}</mat-error>
                            }
                        </mat-form-field>
                    }
                    @case ('stream') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.streamTimestamp }}</mat-label>
                            <input matInput required name="streamTimestamp" [(ngModel)]="model.streamTimestamp" />
                            @if (keyForm.controls['streamTimestamp']?.invalid && keyForm.controls['streamTimestamp']?.touched) {
                                <mat-error>{{ strings().form?.key?.error?.streamTimestamp }}</mat-error>
                            }
                        </mat-form-field>
                        <div class="info-text">{{ strings().label?.streamTimestampId }}</div>
                    }
                    @case ('timeseries') {
                        @if (options.type === 'add') {
                            <mat-form-field class="full-width">
                                <mat-label>{{ strings().page?.key?.timeseries?.retention }} (ms)</mat-label>
                                <input matInput type="number" name="tsRetention" [(ngModel)]="model.tsRetention" />
                                <mat-hint>{{ strings().page?.key?.timeseries?.retentionHint }}</mat-hint>
                            </mat-form-field>
                            <mat-form-field class="full-width">
                                <mat-label>{{ strings().page?.key?.timeseries?.duplicatePolicy }}</mat-label>
                                <mat-select name="tsDuplicatePolicy" [(ngModel)]="model.tsDuplicatePolicy">
                                    <mat-option value="LAST">LAST</mat-option>
                                    <mat-option value="FIRST">FIRST</mat-option>
                                    <mat-option value="MIN">MIN</mat-option>
                                    <mat-option value="MAX">MAX</mat-option>
                                    <mat-option value="SUM">SUM</mat-option>
                                    <mat-option value="BLOCK">BLOCK</mat-option>
                                </mat-select>
                            </mat-form-field>
                        }
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().page?.key?.timeseries?.labels }}</mat-label>
                            <input matInput name="tsLabels" [(ngModel)]="model.tsLabels" />
                            <mat-hint>{{ strings().page?.key?.timeseries?.labelsHint }}</mat-hint>
                        </mat-form-field>
                        @if (!model.tsBulkMode) {
                            <mat-form-field class="full-width">
                                <mat-label>{{ strings().page?.key?.timeseries?.timestamp }}</mat-label>
                                <input matInput name="tsTimestamp" [(ngModel)]="model.tsTimestamp" [disabled]="model.originalTimestamp !== undefined" />
                                <mat-hint>{{ strings().page?.key?.timeseries?.timestampHint }}</mat-hint>
                            </mat-form-field>
                        }
                        @if (model.originalTimestamp === undefined) {
                            <mat-slide-toggle [(ngModel)]="model.tsBulkMode" name="tsBulkMode" style="display: block; margin: 8px 0;">
                                {{ strings().page?.key?.timeseries?.bulkMode }}
                            </mat-slide-toggle>
                        }
                    }
                    @case ('bloom') {
                        <div style="display: flex; gap: 16px; flex-wrap: wrap;">
                            <mat-form-field style="flex: 1; min-width: 140px;">
                                <mat-label>{{ strings().form?.key?.field?.errorRate }}</mat-label>
                                <input matInput type="number" step="0.001" name="bloomErrorRate" [(ngModel)]="model.bloomErrorRate" placeholder="0.01 = 1%" />
                            </mat-form-field>
                            <mat-form-field style="flex: 1; min-width: 140px;">
                                <mat-label>{{ strings().form?.key?.field?.capacity }}</mat-label>
                                <input matInput type="number" name="bloomCapacity" [(ngModel)]="model.bloomCapacity" />
                            </mat-form-field>
                        </div>
                    }
                    @case ('cuckoo') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.capacity }}</mat-label>
                            <input matInput type="number" name="cuckooCapacity" [(ngModel)]="model.cuckooCapacity" />
                        </mat-form-field>
                    }
                    @case ('topk') {
                        <div style="display: flex; gap: 16px; flex-wrap: wrap;">
                            <mat-form-field style="flex: 1; min-width: 100px;">
                                <mat-label>Top K</mat-label>
                                <input matInput type="number" name="topkK" [(ngModel)]="model.topkK" />
                            </mat-form-field>
                            <mat-form-field style="flex: 1; min-width: 100px;">
                                <mat-label>{{ strings().form?.key?.field?.width }}</mat-label>
                                <input matInput type="number" name="topkWidth" [(ngModel)]="model.topkWidth" />
                            </mat-form-field>
                            <mat-form-field style="flex: 1; min-width: 100px;">
                                <mat-label>{{ strings().form?.key?.field?.depth }}</mat-label>
                                <input matInput type="number" name="topkDepth" [(ngModel)]="model.topkDepth" />
                            </mat-form-field>
                            <mat-form-field style="flex: 1; min-width: 100px;">
                                <mat-label>{{ strings().form?.key?.field?.decay }}</mat-label>
                                <input matInput type="number" step="0.1" name="topkDecay" [(ngModel)]="model.topkDecay" />
                            </mat-form-field>
                        </div>
                    }
                    @case ('cms') {
                        <div style="display: flex; gap: 16px; flex-wrap: wrap;">
                            <mat-form-field style="flex: 1; min-width: 140px;">
                                <mat-label>{{ strings().form?.key?.field?.width }}</mat-label>
                                <input matInput type="number" name="cmsWidth" [(ngModel)]="model.cmsWidth" />
                            </mat-form-field>
                            <mat-form-field style="flex: 1; min-width: 140px;">
                                <mat-label>{{ strings().form?.key?.field?.depth }}</mat-label>
                                <input matInput type="number" name="cmsDepth" [(ngModel)]="model.cmsDepth" />
                            </mat-form-field>
                        </div>
                    }
                    @case ('tdigest') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().form?.key?.field?.compression }}</mat-label>
                            <input matInput type="number" name="tdigestCompression" [(ngModel)]="model.tdigestCompression" />
                        </mat-form-field>
                    }
                    @case ('vectorset') {
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().page?.key?.vectorset?.elementName }}</mat-label>
                            <input matInput name="vectorElement" [(ngModel)]="model.vectorElement" />
                        </mat-form-field>
                        <mat-form-field class="full-width">
                            <mat-label>{{ strings().page?.key?.vectorset?.vectorValues }}</mat-label>
                            <input matInput name="vectorValues" [(ngModel)]="model.vectorValues" placeholder="0.1, 0.2, 0.3" />
                        </mat-form-field>
                    }
                }

                <!-- Buffer upload -->
                <input type="file" #fileInput style="display: none" (change)="onFileSelected($event)" />
                @if (model.type !== 'stream' && model.type !== 'timeseries' && !isProbabilisticType()) {
                    <button mat-raised-button class="btn-primary p3xr-action-btn" type="button" (click)="fileInput.click()"
                        [matTooltip]="isWide ? '' : (strings().intention?.setBuffer)">
                        <mat-icon>upload</mat-icon>
                        @if (isWide) { <span>{{ strings().intention?.setBuffer }}</span> }
                    </button>
                }

                @if (model.type !== 'timeseries' && !isProbabilisticType()) {
                    <button mat-raised-button class="btn-primary p3xr-action-btn" type="button" (click)="openJsonEditor()"
                        [matTooltip]="isWide ? '' : (strings().intention?.jsonViewEditor)">
                        <mat-icon>description</mat-icon>
                        @if (isWide) { <span>{{ strings().intention?.jsonViewEditor }}</span> }
                    </button>

                    <button mat-raised-button class="btn-primary p3xr-action-btn" type="button" (click)="formatJson()"
                        [matTooltip]="isWide ? '' : (strings().intention?.formatJson)">
                        <mat-icon>format_line_spacing</mat-icon>
                        @if (isWide) { <span>{{ strings().intention?.formatJson }}</span> }
                    </button>

                    <button mat-raised-button class="btn-accent p3xr-action-btn" type="button" (click)="openJsonViewer()"
                        [matTooltip]="isWide ? '' : (strings().intention?.jsonViewShow)">
                        <mat-icon>account_tree</mat-icon>
                        @if (isWide) { <span>{{ strings().intention?.jsonViewShow }}</span> }
                    </button>
                }

                <div style="margin: 8px 0;">
                    <button mat-raised-button class="btn-accent p3xr-action-btn" type="button" (click)="copy()"
                        [matTooltip]="isWide ? '' : (strings().intention?.copy)">
                        <mat-icon>content_copy</mat-icon>
                        @if (isWide) { <span>{{ strings().intention?.copy }}</span> }
                    </button>
                </div>

                @if (model.type !== 'timeseries' && !isProbabilisticType()) {
                    <mat-slide-toggle [(ngModel)]="validateJson" name="validateJson" style="display: block; margin: 8px 0;">
                        {{ strings().label?.validateJson }}
                    </mat-slide-toggle>

                    @if (model.type === 'stream') {
                        <div class="info-text">{{ strings().label?.streamValue }}</div>
                    }

                    @if (isBuffer) {
                        <div class="info-text">
                            {{ strings().label?.isBuffer?.({ maxValueAsBuffer: getMaxValueAsBufferText() }) }}
                            {{ bufferDisplay(model.value) }}
                        </div>
                    }
                }

                @if (model.type === 'timeseries' && (model.tsEditAll || model.tsBulkMode)) {
                    <div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 8px;">
                        <mat-form-field style="min-width: 140px; flex: 1;" subscriptSizing="dynamic">
                            <mat-label>{{ strings().page?.key?.timeseries?.autoSpread }}</mat-label>
                            <mat-select name="tsSpread" [(ngModel)]="model.tsSpread">
                                <mat-option [value]="1000">1 {{ strings().time?.second }}</mat-option>
                                <mat-option [value]="30000">30 {{ strings().time?.seconds }}</mat-option>
                                <mat-option [value]="60000">1 {{ strings().time?.minute }}</mat-option>
                                <mat-option [value]="1800000">30 {{ strings().time?.minutes }}</mat-option>
                                <mat-option [value]="3600000">1 {{ strings().time?.hour }}</mat-option>
                                <mat-option [value]="86400000">24 {{ strings().time?.hours }}</mat-option>
                            </mat-select>
                        </mat-form-field>

                        <mat-form-field style="min-width: 120px; flex: 1;" subscriptSizing="dynamic">
                            <mat-label>{{ strings().page?.key?.timeseries?.formula }}</mat-label>
                            <mat-select name="tsFormula" [(ngModel)]="model.tsFormula">
                                <mat-option value="">{{ strings().page?.key?.timeseries?.none }}</mat-option>
                                <mat-option value="sin">sin</mat-option>
                                <mat-option value="cos">cos</mat-option>
                                <mat-option value="linear">{{ strings().page?.key?.timeseries?.formulaLinear }}</mat-option>
                                <mat-option value="random">{{ strings().page?.key?.timeseries?.formulaRandom }}</mat-option>
                                <mat-option value="sawtooth">{{ strings().page?.key?.timeseries?.formulaSawtooth }}</mat-option>
                            </mat-select>
                        </mat-form-field>
                    </div>

                    @if (model.tsFormula) {
                        <div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 8px;">
                            <mat-form-field style="min-width: 80px; flex: 1;" subscriptSizing="dynamic">
                                <mat-label>{{ strings().page?.key?.timeseries?.formulaPoints }}</mat-label>
                                <input matInput type="number" name="tsFormulaPoints" [(ngModel)]="model.tsFormulaPoints" min="1" max="10000" />
                            </mat-form-field>
                            <mat-form-field style="min-width: 80px; flex: 1;" subscriptSizing="dynamic">
                                <mat-label>{{ strings().page?.key?.timeseries?.formulaAmplitude }}</mat-label>
                                <input matInput type="number" name="tsFormulaAmplitude" [(ngModel)]="model.tsFormulaAmplitude" />
                            </mat-form-field>
                            <mat-form-field style="min-width: 80px; flex: 1;" subscriptSizing="dynamic">
                                <mat-label>{{ strings().page?.key?.timeseries?.formulaOffset }}</mat-label>
                                <input matInput type="number" name="tsFormulaOffset" [(ngModel)]="model.tsFormulaOffset" />
                            </mat-form-field>
                            <button mat-raised-button class="btn-accent p3xr-action-btn" type="button" (click)="generateFormula()">
                                <mat-icon>auto_graph</mat-icon>
                                @if (isWide) { <span>{{ strings().page?.key?.timeseries?.generate }}</span> }
                            </button>
                        </div>
                    }

                    <mat-form-field class="full-width">
                        <mat-label>{{ strings().page?.key?.timeseries?.dataPoints }}</mat-label>
                        <textarea matInput required name="value" [(ngModel)]="model.value" rows="10"
                                  style="font-family: 'Roboto Mono', monospace; font-size: 13px;"></textarea>
                        <mat-hint>{{ strings().page?.key?.timeseries?.editAllHint }}</mat-hint>
                        @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) {
                            <mat-error>{{ strings().form?.key?.error?.value }}</mat-error>
                        }
                    </mat-form-field>
                } @else if (model.type === 'timeseries' && !model.tsBulkMode) {
                    <mat-form-field class="full-width">
                        <mat-label>{{ strings().page?.key?.timeseries?.value }}</mat-label>
                        <input matInput type="number" required name="value" [(ngModel)]="model.value" />
                        @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) {
                            <mat-error>{{ strings().form?.key?.error?.value }}</mat-error>
                        }
                    </mat-form-field>
                } @else if (!isProbabilisticType()) {
                    <mat-form-field class="full-width">
                        <mat-label>{{ strings().form?.key?.field?.value }}</mat-label>
                        <textarea matInput required name="value" [(ngModel)]="model.value" rows="5"></textarea>
                        @if (keyForm.controls['value']?.invalid && keyForm.controls['value']?.touched) {
                            <mat-error>{{ strings().form?.key?.error?.value }}</mat-error>
                        }
                    </mat-form-field>
                }
            </mat-dialog-content>

            <mat-dialog-actions class="p3xr-dialog-actions">
                <p3xr-dialog-cancel (cancel)="cancel()"></p3xr-dialog-cancel>
                @if (!isReadonly) {
                    <button mat-raised-button class="btn-primary" type="submit">
                        <mat-icon>{{ options.type === 'edit' ? 'edit' : 'add' }}</mat-icon>
                        {{ options.type === 'edit' ? (strings().intention?.save) : (strings().intention?.add) }}
                    </button>
                }
            </mat-dialog-actions>
        </form>
    `,
    styles: [`
        .full-width { width: 100%; }
        .info-text { opacity: 0.5; font-size: 12px; margin-bottom: 8px; }
        .hide-sm { display: inline; }
        @media (max-width: 959px) { .hide-sm { display: none; } }
    `],
})
export class KeyNewOrSetDialogComponent implements OnInit {
    model: any = {};
    options: KeyNewOrSetDialogData;
    get types(): string[] {
        const base = ['string', 'list', 'hash', 'set', 'zset', 'stream'];
        if (this.state.hasTimeSeries()) {
            base.push('timeseries');
        }
        if (this.state.hasReJSON()) {
            base.push('json');
        }
        if (this.state.hasBloom()) {
            base.push('bloom', 'cuckoo', 'topk', 'cms', 'tdigest');
        }
        base.push('vectorset');
        return base;
    }
    private static readonly PROBABILISTIC_TYPES = ['bloom', 'cuckoo', 'topk', 'cms', 'tdigest'];
    isProbabilisticType(): boolean {
        return KeyNewOrSetDialogComponent.PROBABILISTIC_TYPES.includes(this.model.type) || this.model.type === 'vectorset';
    }
    validateJson = false;
    isReadonly = false;
    isBuffer = false;
    isWide = window.innerWidth >= 720;
    strings;

    constructor(
        @Inject(MatDialogRef) private dialogRef: MatDialogRef<KeyNewOrSetDialogComponent>,
        @Inject(MAT_DIALOG_DATA) private data: KeyNewOrSetDialogData,
        @Inject(I18nService) private i18n: I18nService,
        @Inject(CommonService) private common: CommonService,
        @Inject(SocketService) private socket: SocketService,
        @Inject(JsonViewDialogService) private jsonViewDialog: JsonViewDialogService,
        @Inject(JsonEditorDialogService) private jsonEditorDialog: JsonEditorDialogService,
        @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
        @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
        @Inject(RedisStateService) private state: RedisStateService,
        @Inject(SettingsService) private settings: SettingsService,
        @Inject(OverlayService) private overlay: OverlayService,
        @Inject(DiffDialogService) private diffDialog: DiffDialogService,
    ) {
        this.strings = this.i18n.strings;
        this.options = data;
    }

    ngOnInit(): void {
        this.isReadonly = this.state.connection()?.readonly === true;
        this.breakpointObserver.observe('(min-width: 720px)').subscribe(r => {
            this.isWide = r.matches;
            this.cdr.markForCheck();
        });

        this.model = {
            type: 'string',
            key: this.data.node?.key ? this.data.node.key + (this.settings.redisTreeDivider() ?? ':') : '',
            value: undefined,
            score: undefined,
            streamTimestamp: undefined,
            tsTimestamp: undefined,
            tsRetention: 0,
            tsDuplicatePolicy: 'LAST',
            tsLabels: '',
            tsBulkMode: false,
            tsSpread: 60000,
            tsFormula: '',
            tsFormulaPoints: 25,
            tsFormulaAmplitude: 100,
            tsFormulaOffset: 0,
            hashKey: undefined,
            index: undefined,
            bloomErrorRate: 0.01,
            bloomCapacity: 100,
            cuckooCapacity: 1024,
            topkK: 10,
            topkWidth: 2000,
            topkDepth: 7,
            topkDecay: 0.9,
            cmsWidth: 2000,
            cmsDepth: 7,
            tdigestCompression: 100,
            vectorElement: '',
            vectorValues: '',
        };

        if (this.data.model) {
            Object.assign(this.model, this.data.model);
        }

        this.isBuffer = typeof this.model.value === 'object' && this.model.value !== null;
    }

    getTitle(): string {
        const s = this.strings();
        if (this.options.type === 'edit') return s.form?.key?.label?.formName?.edit;
        if (this.options.type === 'append') return s.form?.key?.label?.formName?.append;
        return s.form?.key?.label?.formName?.add;
    }

    getMaxValueAsBufferText(): string {
        try {
            return this.settings.prettyBytes(this.settings.maxValueAsBuffer);
        } catch {
            return `${this.settings.maxValueAsBuffer} bytes`;
        }
    }

    bufferDisplay(value: any): string {
        if (value?.byteLength !== undefined) {
            return '(' + this.settings.prettyBytes(value.byteLength) + ')';
        }
        return '';
    }

    async copy(): Promise<void> {
        let value = this.model.value;
        if (this.model.type === 'timeseries') {
            value = `TS.ADD ${this.model.key} ${this.model.tsTimestamp || '*'} ${this.model.value}`;
        }
        await this.settings.clipboard(value);
        this.common.toast(this.strings().status?.dataCopied);
    }

    async openJsonViewer(): Promise<void> {
        await this.jsonViewDialog.show({ value: this.model.value });
    }

    async openJsonEditor(): Promise<void> {
        try {
            const result = await this.jsonEditorDialog.show({ value: this.model.value });
            this.model.value = result.obj;
        } catch (e) { /* cancelled */ }
    }

    formatJson(): void {
        try {
            this.model.value = JSON.stringify(JSON.parse(this.model.value), null, this.settings.jsonFormat() ?? 2);
        } catch (e) {
            this.common.toast(this.strings().label?.jsonViewNotParsable);
        }
    }

    async onFileSelected(event: Event): Promise<void> {
        const input = event.target as HTMLInputElement;
        const file = input.files?.[0];
        if (!file) return;

        try {
            await this.common.confirm({ message: this.strings().confirm?.uploadBuffer });
            const arrayBuffer = await file.arrayBuffer();
            this.model.value = arrayBuffer;
            this.isBuffer = true;
            this.common.toast(this.strings().confirm?.uploadBufferDone);
        } catch (e) { /* cancelled */ }

        input.value = '';
    }

    async submit(): Promise<void> {
        if (!this.model.key || this.model.key.trim().length === 0) {
            this.common.toast(this.strings().form?.key?.error?.key);
            return;
        }

        if (this.validateJson) {
            try {
                JSON.parse(this.model.value);
            } catch (e) {
                this.common.toast(this.strings().label?.jsonViewNotParsable);
                return;
            }
        }

        try {
            // Show diff for edits (not new keys)
            if (this.data.model?.value !== undefined && this.data.model.value !== this.model.value) {
                const confirmed = await this.diffDialog.show({
                    keyName: this.model.key,
                    fieldName: this.model.hashKey || undefined,
                    oldValue: String(this.data.model.value),
                    newValue: String(this.model.value),
                });
                if (!confirmed) return;
            }

            this.overlay.show();
            const response = await this.socket.request({
                action: 'key/new-or-set',
                payload: {
                    type: this.options.type,
                    originalValue: this.data.model?.value,
                    originalHashKey: this.data.model?.hashKey,
                    model: structuredClone(this.model),
                },
            });

            if (typeof window['gtag'] === 'function') {
                window['gtag']('config', this.settings.googleAnalytics, { page_path: '/key-new-or-set' });
            }

            this.common.toast(this.strings().status?.set);
            this.dialogRef.close(response);
        } catch (e) {
            this.common.generalHandleError(e);
        } finally {
            this.overlay.hide();
        }
    }

    generateFormula(): void {
        const points = Math.min(Math.max(parseInt(this.model.tsFormulaPoints) || 25, 1), 10000);
        const amplitude = parseFloat(this.model.tsFormulaAmplitude) || 100;
        const offset = parseFloat(this.model.tsFormulaOffset) || 0;
        const formula = this.model.tsFormula;
        const lines: string[] = [];

        for (let i = 0; i < points; i++) {
            const x = i / points;
            let value: number;
            switch (formula) {
                case 'sin':
                    value = Math.sin(x * Math.PI * 2) * amplitude + offset;
                    break;
                case 'cos':
                    value = Math.cos(x * Math.PI * 2) * amplitude + offset;
                    break;
                case 'linear':
                    value = x * amplitude + offset;
                    break;
                case 'random':
                    value = Math.random() * amplitude + offset;
                    break;
                case 'sawtooth':
                    value = (x % 0.25) * 4 * amplitude + offset;
                    break;
                default:
                    value = offset;
            }
            lines.push(`* ${parseFloat(value.toFixed(4))}`);
        }
        this.model.value = lines.join('\n');
    }

    cancel(): void {
        this.dialogRef.close(undefined);
    }
}