The extension will keep evolving. Your feedback is invaluable in shaping its future. Feel free to share your thoughts and suggestions!
-
Version: 0.5.0 beta
+
Version: 0.6.0 beta
diff --git a/src/tools/components/aes-encryption/AesEncryption.ts b/src/tools/components/aes-encryption/AesEncryption.ts
index deb6edb..f845b33 100644
--- a/src/tools/components/aes-encryption/AesEncryption.ts
+++ b/src/tools/components/aes-encryption/AesEncryption.ts
@@ -33,6 +33,11 @@ export class AesEncryption extends BaseTool {
@query('#input') private input!: HTMLTextAreaElement;
@query('#output') private output!: HTMLTextAreaElement;
@query('#file-input') private fileInput!: HTMLInputElement;
+ @query('#password') private passwordInput!: HTMLInputElement;
+
+ firstUpdated() {
+ setTimeout(() => this.passwordInput?.focus(), 0);
+ }
private styles = css`
${BaseTool.styles}
diff --git a/src/tools/components/ascii-encoder/AsciiEncoder.ts b/src/tools/components/ascii-encoder/AsciiEncoder.ts
index 0e59771..c0974dc 100644
--- a/src/tools/components/ascii-encoder/AsciiEncoder.ts
+++ b/src/tools/components/ascii-encoder/AsciiEncoder.ts
@@ -1,5 +1,5 @@
import { html, css } from 'lit';
-import { customElement, state } from 'lit/decorators.js';
+import { customElement, query, state } from 'lit/decorators.js';
import { BaseTool } from '../../base/BaseTool';
import {
adjustTextareaHeight,
@@ -14,6 +14,12 @@ export class AsciiEncoder extends BaseTool {
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
@state() private isCopied = false;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
/* Minimal local styling if needed. */
diff --git a/src/tools/components/base64-encoder/Base64Encoder.ts b/src/tools/components/base64-encoder/Base64Encoder.ts
index db5970e..c2ff872 100644
--- a/src/tools/components/base64-encoder/Base64Encoder.ts
+++ b/src/tools/components/base64-encoder/Base64Encoder.ts
@@ -6,6 +6,7 @@ import {
renderCopyButton
} from '../../../utils/util';
import mimeDb from 'mime-db';
+import { detectMimeType } from './mimeUtils';
@customElement('base64-encoder')
export class Base64Encoder extends BaseTool {
@@ -23,10 +24,14 @@ export class Base64Encoder extends BaseTool {
@state() private isShowUriHeader = false;
@state() private decodedFileSize = 0;
- @query('#input') input!: HTMLTextAreaElement;
- @query('#output') output!: HTMLTextAreaElement;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+ @query('#output') outputElement!: HTMLTextAreaElement;
@query('#file-input') fileInput!: HTMLInputElement;
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
/* Minimal local styling if needed. */
@@ -177,8 +182,8 @@ export class Base64Encoder extends BaseTool {
protected updated(changedProperties: Map): void {
super.updated(changedProperties);
- if (this.output && changedProperties.has('outputText')) {
- adjustTextareaHeight(this.output);
+ if (this.outputElement && changedProperties.has('outputText')) {
+ adjustTextareaHeight(this.outputElement);
}
}
@@ -253,14 +258,41 @@ export class Base64Encoder extends BaseTool {
// Handle data:URI format (e.g. data:image/png;base64,...)
if (base64Data.startsWith('data:')) {
- const [header, content] = base64Data.split(',');
+ const commaIndex = base64Data.indexOf(',');
+ if (commaIndex === -1) {
+ this.hideOutput();
+ this.alert = {
+ type: 'error',
+ message: 'Invalid data URI format: missing comma separator'
+ };
+ return;
+ }
+
+ const header = base64Data.substring(0, commaIndex);
+ const content = base64Data.substring(commaIndex + 1);
+
+ // Check if the data URI explicitly contains base64 encoding
+ if (!header.includes(';base64')) {
+ this.hideOutput();
+ this.alert = {
+ type: 'error',
+ message: 'Data URI must contain ";base64" for Base64 decoding. Plain data URIs are not supported.'
+ };
+ return;
+ }
+
base64Data = content;
this.decodedMimeType = header.split(';')[0].split(':')[1];
- this.decodedData = this.base64ToUint8Array(content);
+ if (this.decodedMimeType.startsWith('text/plain')) {
+ const bytes = this.base64ToUint8Array(content);
+ this.decodedData = new TextDecoder('utf-8').decode(bytes);
+ } else {
+ this.decodedData = this.base64ToUint8Array(content);
+ }
} else {
try {
this.decodedData = this.base64ToUint8Array(base64Data);
- this.decodedMimeType = this.detectMimeType(this.decodedData);
+ this.decodedMimeType = detectMimeType(this.decodedData);
if (this.decodedMimeType === 'text/plain') {
// Attempt to decode as Base64
@@ -334,118 +366,6 @@ export class Base64Encoder extends BaseTool {
return bytes;
}
- /**
- * Detects the MIME type of a file based on its binary signature/magic numbers
- * @param data - Uint8Array containing the file's binary data to analyze
- * @returns string - The detected MIME type
- */
- private detectMimeType(data: Uint8Array): string {
- if (data.length === 0) {
- return 'text/plain';
- }
-
- // Define file signatures with variable lengths for better accuracy
- // Format: [mimeType, [signature bytes], minBytesRequired]
- const signatures: Array<[string, number[], number]> = [
- // Images
- ['image/jpeg', [0xFF, 0xD8, 0xFF], 3],
- ['image/png', [0x89, 0x50, 0x4E, 0x47], 4],
- ['image/gif', [0x47, 0x49, 0x46, 0x38], 4],
- ['image/webp', [0x52, 0x49, 0x46, 0x46], 4],
- ['image/bmp', [0x42, 0x4D], 2],
- ['image/tiff', [0x49, 0x49, 0x2A, 0x00], 4], // Little-endian
- ['image/tiff', [0x4D, 0x4D, 0x00, 0x2A], 4], // Big-endian
- ['image/svg+xml', [0x3C, 0x3F, 0x78, 0x6D], 4],
- ['image/x-icon', [0x00, 0x00, 0x01, 0x00], 4],
-
- // Documents & Archives
- ['application/pdf', [0x25, 0x50, 0x44, 0x46], 4], // %PDF
- ['application/zip', [0x50, 0x4B, 0x03, 0x04], 4], // PK.. (zip, jar, docx, xlsx, etc)
- ['application/zip', [0x50, 0x4B, 0x05, 0x06], 4], // PK.. (empty zip)
- ['application/zip', [0x50, 0x4B, 0x07, 0x08], 4], // PK.. (spanned zip)
- ['application/x-rar-compressed', [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 6],
- ['application/x-7z-compressed', [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 6],
- ['application/gzip', [0x1F, 0x8B, 0x08], 3],
- ['application/x-tar', [0x75, 0x73, 0x74, 0x61, 0x72], 5],
- ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', [0x50, 0x4B, 0x03, 0x04], 4],
- ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', [0x50, 0x4B, 0x03, 0x04], 4],
- ['application/vnd.openxmlformats-officedocument.presentationml.presentation', [0x50, 0x4B, 0x03, 0x04], 4],
- ['application/xml', [0x3C, 0x3F, 0x78, 0x6D], 4],
-
- // Text formats
- ['application/json', [0x7B], 1],
- ['application/json', [0x5B], 1],
- ['text/html', [0x3C, 0x21, 0x44, 0x4F], 4],
- ['text/html', [0x3C, 0x48, 0x54, 0x4D], 4],
- ['text/html', [0x3C, 0x68, 0x74, 0x6D], 4],
- ['text/css', [0x2F, 0x2A], 2],
- ['application/javascript', [0x2F, 0x2F], 2],
- ['text/plain', [0xEF, 0xBB, 0xBF], 3],
-
- // Audio
- ['audio/mpeg', [0xFF, 0xFB], 2], // MP3 (MPEG-1)
- ['audio/mpeg', [0xFF, 0xFA], 2], // MP3 (MPEG-2)
- ['audio/mpeg', [0xFF, 0xF3], 2], // MP3 (MPEG-2.5)
- ['audio/mpeg', [0x49, 0x44, 0x33], 3], // ID3 tag
- ['audio/wav', [0x52, 0x49, 0x46, 0x46], 4],
- ['audio/flac', [0x66, 0x4C, 0x61, 0x43], 4],
- ['audio/aac', [0xFF, 0xF1], 2],
- ['audio/ogg', [0x4F, 0x67, 0x67, 0x53], 4],
-
- // Video
- ['video/mp4', [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70], 8],
- ['video/quicktime', [0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70], 8],
- ['video/x-msvideo', [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x56, 0x49], 11],
- ['video/x-matroska', [0x1A, 0x45, 0xDF, 0xA3], 4],
- ['video/mpeg', [0x00, 0x00, 0x01, 0xB3], 4],
- ['video/x-flv', [0x46, 0x4C, 0x56, 0x01], 4],
- ['video/quicktime', [0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20], 7],
-
- // Executables & System
- ['application/x-msdownload', [0x4D, 0x5A], 2],
- ['application/x-mach-binary', [0xFE, 0xED, 0xFA, 0xCE], 4],
- ['application/x-mach-binary', [0xFE, 0xED, 0xFA, 0xCF], 4],
- ['application/x-executable', [0x7F, 0x45, 0x4C, 0x46], 4],
- ['application/x-sharedlib', [0x7F, 0x45, 0x4C, 0x46], 4],
-
- // Fonts
- ['font/ttf', [0x00, 0x01, 0x00, 0x00], 4],
- ['font/otf', [0x4F, 0x54, 0x54, 0x4F], 4],
- ['font/woff', [0x77, 0x4F, 0x46, 0x46], 4],
- ['font/woff2', [0x77, 0x4F, 0x46, 0x32], 4],
-
- // Database & Data
- ['application/x-sqlite3', [0x53, 0x51, 0x4C, 0x69], 4],
- ['application/vnd.google-earth.kml+xml', [0x3C, 0x3F, 0x78, 0x6D], 4],
- ];
-
- for (const [mimeType, signature, minBytes] of signatures) {
- if (data.length >= minBytes) {
- // Handle variable-length signatures and masks
- let match = true;
- for (let i = 0; i < signature.length; i++) {
- if (data[i] !== signature[i]) {
- match = false;
- break;
- }
- }
- if (match) {
- return mimeType;
- }
- }
- }
-
- // Secondary detection: Check for tar format (signature at offset 257)
- if (data.length > 262) {
- const tarSignature = [0x75, 0x73, 0x74, 0x61, 0x72];
- if (tarSignature.every((byte, i) => data[257 + i] === byte)) {
- return 'application/x-tar';
- }
- }
-
- return 'text/plain';
- }
-
private triggerFileInput(): void {
this.fileInput.click();
}
@@ -491,20 +411,20 @@ export class Base64Encoder extends BaseTool {
return new Promise((resolve, reject) => {
const reader = new FileReader();
- reader.onload = async (e) => {
+ reader.onload = (e) => {
try {
const result = e.target?.result;
- if (typeof result === 'string') {
- this.outputText = btoa(result);
- } else if (result instanceof ArrayBuffer) {
- const bytes = new Uint8Array(result);
- let binary = '';
- // Convert byte array to binary string
- bytes.forEach(byte => binary += String.fromCharCode(byte));
- this.outputText = btoa(binary);
+ if (!(result instanceof ArrayBuffer)) {
+ reject(new Error('Failed to read file as binary data.'));
+ return;
}
+
+ this.outputText = this.arrayBufferToBase64(result);
this.uriHeader = `data:${this.inputMimeType};base64,`;
- this.outputText = this.isShowUriHeader ? `${this.uriHeader}${this.outputText}` : this.outputText;
+ this.outputText = this.isShowUriHeader
+ ? `${this.uriHeader}${this.outputText}`
+ : this.outputText;
+
this.requestUpdate();
resolve();
} catch (error) {
@@ -513,51 +433,10 @@ export class Base64Encoder extends BaseTool {
};
reader.onerror = () => reject(reader.error);
- if (file.type.startsWith('text/')) {
- reader.readAsText(file);
- } else {
- reader.readAsArrayBuffer(file);
- }
+ reader.readAsArrayBuffer(file);
});
}
- private clearAll(): void {
- this.inputText = '';
- this.outputText = '';
- this.fileName = '';
- this.file = null;
- this.fileInput.value = '';
- this.outputMode = 'text';
- this.input.style.height = `28px`;
- this.uriHeader = '';
- this.alert = null;
- this.decodedFileSize = 0;
- this.renderOutput();
- this.requestUpdate();
- }
-
- private async copyToClipboard() {
- if (!this.outputText) {
- return;
- }
-
- try {
- await navigator.clipboard.writeText(this.outputText);
- this.isCopied = true;
- setTimeout(() => {
- this.isCopied = false;
- }, 2000);
- } catch (err) {
- this.isCopied = false;
- }
- }
-
- private hideOutput() {
- this.outputText = '';
- this.outputMode = 'error';
- this.requestUpdate();
- }
-
private getBase64String(base64Data: string): { base64: string; mimeType: string } {
if (base64Data.startsWith('data:')) {
const [header, content] = base64Data.split(',');
@@ -567,6 +446,19 @@ export class Base64Encoder extends BaseTool {
return { base64: base64Data, mimeType: this.decodedMimeType };
}
+ private arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ const chunkSize = 0x8000; // avoid call stack/memory issues on larger files
+ let binary = '';
+
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ const chunk = bytes.subarray(i, i + chunkSize);
+ binary += String.fromCharCode(...chunk);
+ }
+
+ return btoa(binary);
+ }
+
private getDownloadButtonText(): string {
const extension = this.getFileExtension();
if (extension) {
@@ -605,4 +497,41 @@ export class Base64Encoder extends BaseTool {
}
return '';
}
+
+ private clearAll(): void {
+ this.inputText = '';
+ this.outputText = '';
+ this.fileName = '';
+ this.file = null;
+ this.fileInput.value = '';
+ this.outputMode = 'text';
+ this.inputElement.style.height = `28px`;
+ this.uriHeader = '';
+ this.alert = null;
+ this.decodedFileSize = 0;
+ this.renderOutput();
+ this.requestUpdate();
+ }
+
+ private async copyToClipboard() {
+ if (!this.outputText) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(this.outputText);
+ this.isCopied = true;
+ setTimeout(() => {
+ this.isCopied = false;
+ }, 2000);
+ } catch (err) {
+ this.isCopied = false;
+ }
+ }
+
+ private hideOutput() {
+ this.outputText = '';
+ this.outputMode = 'error';
+ this.requestUpdate();
+ }
}
\ No newline at end of file
diff --git a/src/tools/components/base64-encoder/mimeUtils.ts b/src/tools/components/base64-encoder/mimeUtils.ts
new file mode 100644
index 0000000..045250f
--- /dev/null
+++ b/src/tools/components/base64-encoder/mimeUtils.ts
@@ -0,0 +1,140 @@
+/**
+ * Detects the MIME type of a file based on its binary signature/magic numbers
+ * @param data - Uint8Array containing the file's binary data to analyze
+ * @returns string - The detected MIME type
+ */
+export function detectMimeType(data: Uint8Array): string {
+ if (!data || data.length === 0) {
+ return 'application/octet-stream';
+ }
+
+ // RIFF family (must check subtype at offset 8)
+ // RIFF....WEBP / WAVE / AVI
+ if (hasAsciiAt(data, 0, 'RIFF') && data.length >= 12) {
+ const riffType = readAscii(data, 8, 4);
+ if (riffType === 'WEBP') return 'image/webp';
+ if (riffType === 'WAVE') return 'audio/wav';
+ if (riffType === 'AVI ') return 'video/x-msvideo';
+ }
+
+ // ISO BMFF / MP4 family
+ // Typical structure: [size:4][ftyp:4][major_brand:4]...
+ if (data.length >= 12 && hasAsciiAt(data, 4, 'ftyp')) {
+ const majorBrand = readAscii(data, 8, 4);
+
+ // Known brands (not exhaustive but robust for mp4/audio mp4/quicktime)
+ const videoMp4Brands = new Set([
+ 'isom', 'iso2', 'iso3', 'iso4',
+ 'mp41', 'mp42',
+ 'avc1', 'dash',
+ '3gp4', '3gp5', '3gp6',
+ 'F4V ', 'M4V '
+ ]);
+
+ const audioMp4Brands = new Set([
+ 'M4A ', 'M4B ', 'M4P ', 'f4a ', 'f4b '
+ ]);
+
+ if (majorBrand === 'qt ') return 'video/quicktime';
+ if (videoMp4Brands.has(majorBrand)) return 'video/mp4';
+ if (audioMp4Brands.has(majorBrand)) return 'audio/mp4';
+
+ // Unknown BMFF brand: still safer than text/plain
+ return 'application/octet-stream';
+ }
+
+ // High-confidence signatures
+ if (matchBytes(data, [0xFF, 0xD8, 0xFF])) return 'image/jpeg';
+ if (matchBytes(data, [0x89, 0x50, 0x4E, 0x47])) return 'image/png';
+ if (matchBytes(data, [0x47, 0x49, 0x46, 0x38])) return 'image/gif';
+ if (matchBytes(data, [0x42, 0x4D])) return 'image/bmp';
+ if (matchBytes(data, [0x00, 0x00, 0x01, 0x00])) return 'image/x-icon';
+
+ if (matchBytes(data, [0x25, 0x50, 0x44, 0x46])) return 'application/pdf';
+ if (matchBytes(data, [0x50, 0x4B, 0x03, 0x04]) || matchBytes(data, [0x50, 0x4B, 0x05, 0x06]) || matchBytes(data, [0x50, 0x4B, 0x07, 0x08])) return 'application/zip';
+ if (matchBytes(data, [0x1F, 0x8B, 0x08])) return 'application/gzip';
+ if (matchBytes(data, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07])) return 'application/x-rar-compressed';
+ if (matchBytes(data, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])) return 'application/x-7z-compressed';
+
+ if (matchBytes(data, [0x49, 0x44, 0x33])) return 'audio/mpeg';
+ if (matchBytes(data, [0xFF, 0xFB]) || matchBytes(data, [0xFF, 0xFA]) || matchBytes(data, [0xFF, 0xF3])) return 'audio/mpeg';
+ if (matchBytes(data, [0x66, 0x4C, 0x61, 0x43])) return 'audio/flac';
+ if (matchBytes(data, [0x4F, 0x67, 0x67, 0x53])) return 'audio/ogg';
+
+ if (matchBytes(data, [0x1A, 0x45, 0xDF, 0xA3])) return 'video/x-matroska';
+ if (matchBytes(data, [0x46, 0x4C, 0x56, 0x01])) return 'video/x-flv';
+
+ if (matchBytes(data, [0x7F, 0x45, 0x4C, 0x46])) return 'application/x-executable';
+ if (matchBytes(data, [0x4D, 0x5A])) return 'application/x-msdownload';
+
+ if (isLikelyTextContent(data)) {
+ return 'text/plain';
+ }
+
+ return 'application/octet-stream';
+}
+
+function matchBytes(data: Uint8Array, signature: number[], offset = 0): boolean {
+ if (data.length < offset + signature.length) return false;
+ for (let i = 0; i < signature.length; i++) {
+ if (data[offset + i] !== signature[i]) return false;
+ }
+ return true;
+}
+
+function hasAsciiAt(data: Uint8Array, offset: number, text: string): boolean {
+ if (data.length < offset + text.length) return false;
+ for (let i = 0; i < text.length; i++) {
+ if (data[offset + i] !== text.charCodeAt(i)) return false;
+ }
+ return true;
+}
+
+function readAscii(data: Uint8Array, offset: number, length: number): string {
+ if (data.length < offset + length) return '';
+ let out = '';
+ for (let i = 0; i < length; i++) {
+ out += String.fromCharCode(data[offset + i]);
+ }
+ return out;
+}
+
+function isLikelyTextContent(data: Uint8Array): boolean {
+ if (data.length === 0) return false;
+
+ const hasUtf8Bom = data.length >= 3 && data[0] === 0xEF && data[1] === 0xBB && data[2] === 0xBF;
+ const hasUtf16LEBom = data.length >= 2 && data[0] === 0xFF && data[1] === 0xFE;
+ const hasUtf16BEBom = data.length >= 2 && data[0] === 0xFE && data[1] === 0xFF;
+
+ // Fast binary guard: null bytes are strong binary indicator (unless UTF-16 BOM exists)
+ if (!hasUtf16LEBom && !hasUtf16BEBom) {
+ const sample = data.subarray(0, Math.min(data.length, 4096));
+ for (let i = 0; i < sample.length; i++) {
+ if (sample[i] === 0x00) return false;
+ }
+ }
+
+ try {
+ let decoded = '';
+
+ if (hasUtf16LEBom) {
+ decoded = new TextDecoder('utf-16le', { fatal: false }).decode(data);
+ } else if (hasUtf16BEBom) {
+ const beData = data.subarray(2); // skip BOM
+ const swapped = new Uint8Array(beData.length);
+ for (let i = 0; i + 1 < beData.length; i += 2) {
+ swapped[i] = beData[i + 1];
+ swapped[i + 1] = beData[i];
+ }
+ decoded = new TextDecoder('utf-16le', { fatal: false }).decode(swapped);
+ } else {
+ decoded = new TextDecoder('utf-8', { fatal: true }).decode(hasUtf8Bom ? data.subarray(3) : data);
+ }
+
+ if (!decoded) return false;
+
+ return true;
+ } catch {
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/tools/components/chmod-calculator/ChmodCalculator.ts b/src/tools/components/chmod-calculator/ChmodCalculator.ts
new file mode 100644
index 0000000..02a8c4d
--- /dev/null
+++ b/src/tools/components/chmod-calculator/ChmodCalculator.ts
@@ -0,0 +1,366 @@
+import { css, html } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { BaseTool } from '../../base/BaseTool';
+
+type Subject = 'owner' | 'group' | 'public';
+type Permission = 'read' | 'write' | 'execute';
+
+interface PermissionSet {
+ read: boolean;
+ write: boolean;
+ execute: boolean;
+}
+
+interface PermissionMatrix {
+ owner: PermissionSet;
+ group: PermissionSet;
+ public: PermissionSet;
+}
+
+type AlertState = { type: 'error' | 'warning'; message: string } | null;
+
+@customElement('chmod-calculator')
+export class ChmodCalculator extends BaseTool {
+ private static readonly SUBJECTS: Subject[] = ['owner', 'group', 'public'];
+ private static readonly PERMISSIONS: Permission[] = ['read', 'write', 'execute'];
+
+ @state()
+ private permissions: PermissionMatrix = {
+ owner: { read: true, write: true, execute: false },
+ group: { read: true, write: false, execute: false },
+ public: { read: true, write: false, execute: false }
+ };
+
+ @state() private octalInput = '644';
+ @state() private symbolicInput = 'rw-r--r--';
+ @state() private alert: AlertState = null;
+
+ private readonly styles = css`
+ ${BaseTool.styles}
+
+ .chmod-grid {
+ display: grid;
+ grid-template-columns: 88px repeat(3, 1fr);
+ gap: 6px;
+ align-items: center;
+ }
+
+ .grid-header {
+ font-size: 0.75rem;
+ text-align: center;
+ }
+
+ .grid-row-label {
+ font-size: 0.75rem;
+ }
+
+ .perm-btn {
+ min-height: 30px;
+ border: 1px solid var(--vscode-panel-border);
+ border-radius: 2px;
+ background: var(--vscode-panel-background);
+ color: var(--vscode-foreground);
+ cursor: pointer;
+ transition: background-color 100ms ease-in-out, border-color 100ms ease-in-out;
+ }
+
+ .perm-btn:hover {
+ background: var(--vscode-toolbar-hoverBackground);
+ }
+
+ .perm-btn[aria-pressed='true'] {
+ background: var(--vscode-button-background);
+ color: var(--vscode-button-foreground);
+ border-color: var(--vscode-button-background);
+ }
+
+ .output-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-top: 16px;
+ }
+
+ .output-label {
+ font-size: 0.75rem;
+ opacity: 0.75;
+ margin-bottom: 4px;
+ }
+
+ .output-value {
+ font-family: var(--vscode-editor-font-family);
+ letter-spacing: 0.02em;
+ }
+ `;
+
+ protected renderTool() {
+ return html`
+
+
+ `;
+ }
+
+ private togglePermission(subject: Subject, permission: Permission): void {
+ this.permissions = {
+ ...this.permissions,
+ [subject]: {
+ ...this.permissions[subject],
+ [permission]: !this.permissions[subject][permission]
+ }
+ };
+
+ this.syncOutputsFromPermissions();
+ this.alert = null;
+ }
+
+ private handleOctalInput(event: Event): void {
+ const value = (event.target as HTMLInputElement).value.trim();
+ this.octalInput = value;
+
+ if (value.length === 0) {
+ this.alert = {
+ type: 'warning',
+ message: 'Octal permission is empty. Use 3 digits from 0 to 7 (e.g. 755).'
+ };
+ return;
+ }
+
+ const parsed = this.parseOctalPermission(value);
+ if (parsed) {
+ this.permissions = parsed;
+ this.symbolicInput = this.permissionsToSymbolic(parsed);
+ this.alert = null;
+ return;
+ }
+
+ if (/^[0-7]{1,2}$/.test(value)) {
+ this.alert = {
+ type: 'warning',
+ message: 'Octal permission is incomplete. Please provide exactly 3 digits.'
+ };
+ return;
+ }
+
+ this.alert = {
+ type: 'error',
+ message: 'Invalid octal permission. Use exactly 3 digits, each between 0 and 7 (e.g. 421, 644, 755).'
+ };
+ }
+
+ private handleSymbolicInput(event: Event): void {
+ const rawValue = (event.target as HTMLInputElement).value.trim().toLowerCase();
+ this.symbolicInput = rawValue;
+
+ if (rawValue.length === 0) {
+ this.alert = {
+ type: 'warning',
+ message: 'Symbolic permission is empty. Use 9 characters such as rwxr-xr-x.'
+ };
+ return;
+ }
+
+ const normalized = this.normalizeSymbolicValue(rawValue);
+ const parsed = this.parseSymbolicPermission(normalized);
+
+ if (parsed) {
+ this.permissions = parsed;
+ this.octalInput = this.permissionsToOctal(parsed);
+ this.symbolicInput = normalized;
+ this.alert = null;
+ return;
+ }
+
+ if (normalized.length < 9) {
+ this.alert = {
+ type: 'warning',
+ message: 'Symbolic permission is incomplete. Please provide 9 characters, e.g. r---w---x.'
+ };
+ return;
+ }
+
+ this.alert = {
+ type: 'error',
+ message: 'Invalid symbolic permission. Use 9 chars with r/w/x or -, e.g. rw-r--r-- or r---w---x.'
+ };
+ }
+
+ private syncOutputsFromPermissions(): void {
+ this.octalInput = this.permissionsToOctal(this.permissions);
+ this.symbolicInput = this.permissionsToSymbolic(this.permissions);
+ }
+
+ private permissionsToOctal(matrix: PermissionMatrix): string {
+ const owner = this.toOctalDigit(matrix.owner);
+ const group = this.toOctalDigit(matrix.group);
+ const publicValue = this.toOctalDigit(matrix.public);
+ return `${owner}${group}${publicValue}`;
+ }
+
+ private permissionsToSymbolic(matrix: PermissionMatrix): string {
+ return [
+ this.toSymbolicTriplet(matrix.owner),
+ this.toSymbolicTriplet(matrix.group),
+ this.toSymbolicTriplet(matrix.public)
+ ].join('');
+ }
+
+ private parseOctalPermission(value: string): PermissionMatrix | null {
+ if (!/^[0-7]{3}$/.test(value)) {
+ return null;
+ }
+
+ const [ownerDigit, groupDigit, publicDigit] = value.split('').map(Number);
+
+ return {
+ owner: this.permissionSetFromDigit(ownerDigit),
+ group: this.permissionSetFromDigit(groupDigit),
+ public: this.permissionSetFromDigit(publicDigit)
+ };
+ }
+
+ private parseSymbolicPermission(value: string): PermissionMatrix | null {
+ if (!/^[r-][w-][x-][r-][w-][x-][r-][w-][x-]$/.test(value)) {
+ return null;
+ }
+
+ return {
+ owner: this.permissionSetFromTriplet(value.slice(0, 3)),
+ group: this.permissionSetFromTriplet(value.slice(3, 6)),
+ public: this.permissionSetFromTriplet(value.slice(6, 9))
+ };
+ }
+
+ private normalizeSymbolicValue(value: string): string {
+ // Accept optional file type prefix (e.g. -rwxr-xr-x, drwxr-xr-x)
+ if (/^[\-dcbpls][rwx-]{9}$/.test(value)) {
+ return value.slice(1);
+ }
+ return value;
+ }
+
+ private permissionSetFromDigit(digit: number): PermissionSet {
+ return {
+ read: (digit & 4) !== 0,
+ write: (digit & 2) !== 0,
+ execute: (digit & 1) !== 0
+ };
+ }
+
+ private permissionSetFromTriplet(triplet: string): PermissionSet {
+ return {
+ read: triplet[0] === 'r',
+ write: triplet[1] === 'w',
+ execute: triplet[2] === 'x'
+ };
+ }
+
+ private getPermissionWeight(permission: Permission): number {
+ if (permission === 'read') return 4;
+ if (permission === 'write') return 2;
+ return 1;
+ }
+
+ private toOctalDigit(permissionSet: PermissionSet): number {
+ let value = 0;
+ if (permissionSet.read) value += 4;
+ if (permissionSet.write) value += 2;
+ if (permissionSet.execute) value += 1;
+ return value;
+ }
+
+ private toSymbolicTriplet(permissionSet: PermissionSet): string {
+ const read = permissionSet.read ? 'r' : '-';
+ const write = permissionSet.write ? 'w' : '-';
+ const execute = permissionSet.execute ? 'x' : '-';
+ return `${read}${write}${execute}`;
+ }
+
+ private subjectLabel(subject: Subject): string {
+ if (subject === 'owner') return 'Owner';
+ if (subject === 'group') return 'Group';
+ return 'Public';
+ }
+
+ private permissionLabel(permission: Permission): string {
+ if (permission === 'read') return 'Read';
+ if (permission === 'write') return 'Write';
+ return 'Execute';
+ }
+
+ private permissionShortLabel(permission: Permission): string {
+ if (permission === 'read') return 'R';
+ if (permission === 'write') return 'W';
+ return 'X';
+ }
+}
\ No newline at end of file
diff --git a/src/tools/components/crontab-generator/CrontabGenerator.ts b/src/tools/components/crontab-generator/CrontabGenerator.ts
new file mode 100644
index 0000000..2a60636
--- /dev/null
+++ b/src/tools/components/crontab-generator/CrontabGenerator.ts
@@ -0,0 +1,846 @@
+import { css, html } from 'lit';
+import { customElement, query, state } from 'lit/decorators.js';
+import { BaseTool } from '../../base/BaseTool';
+import { adjustTextareaHeight } from '../../../utils/util';
+
+type AlertKind = 'error' | 'warning';
+
+interface AlertState {
+ type: AlertKind;
+ message: string;
+}
+
+interface ParsedField {
+ raw: string;
+ values: number[];
+ set: Set;
+ any: boolean;
+}
+
+interface ParsedCron {
+ rawFields: [string, string, string, string, string];
+ minute: ParsedField;
+ hour: ParsedField;
+ dayOfMonth: ParsedField;
+ month: ParsedField;
+ dayOfWeek: ParsedField;
+}
+
+interface CommonExample {
+ expression: string;
+ description: string;
+}
+
+interface CronFieldInfo {
+ field: string;
+ range: string;
+ meaning: string;
+}
+
+@customElement('crontab-generator')
+export class CrontabGenerator extends BaseTool {
+ private static readonly MONTH_NAME_MAP: Record = {
+ JAN: 1,
+ FEB: 2,
+ MAR: 3,
+ APR: 4,
+ MAY: 5,
+ JUN: 6,
+ JUL: 7,
+ AUG: 8,
+ SEP: 9,
+ OCT: 10,
+ NOV: 11,
+ DEC: 12
+ };
+
+ private static readonly WEEKDAY_NAME_MAP: Record = {
+ SUN: 0,
+ MON: 1,
+ TUE: 2,
+ WED: 3,
+ THU: 4,
+ FRI: 5,
+ SAT: 6
+ };
+
+ private static readonly MONTH_LABELS: string[] = [
+ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'
+ ];
+
+ private static readonly WEEKDAY_LABELS: string[] = [
+ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
+ ];
+
+ private static readonly COMMON_EXAMPLES: CommonExample[] = [
+ { expression: '* * * * *', description: 'Every minute.' },
+ { expression: '*/5 * * * *', description: 'Every 5 minutes.' },
+ { expression: '0 * * * *', description: 'At minute 0 of every hour.' },
+ { expression: '30 9 * * 1-5', description: 'At 09:30 on weekdays (Mon-Fri).' },
+ { expression: '0 0 * * *', description: 'Every day at midnight.' },
+ { expression: '0 0 1 * *', description: 'At midnight on the 1st of each month.' },
+ ];
+
+ private static readonly FIELD_INFO: CronFieldInfo[] = [
+ { field: 'Minute', range: '0-59', meaning: 'Which minute of the hour to run.' },
+ { field: 'Hour', range: '0-23', meaning: 'Which hour of the day to run.' },
+ { field: 'Day of month', range: '1-31', meaning: 'Which day number in the month to run.' },
+ { field: 'Month', range: '1-12 or JAN-DEC', meaning: 'Which month to run.' },
+ { field: 'Day of week', range: '0-7 or SUN-SAT', meaning: 'Which weekday to run (0 or 7 = Sunday).' }
+ ];
+
+ private readonly styles = css`
+ ${BaseTool.styles}
+
+ .summary-card {
+ background-color: var(--vscode-panel-background);
+ box-shadow: inset 0 0 0 1px var(--vscode-panel-border);
+ border-radius: 2px;
+ padding: 8px 10px;
+ }
+
+ .next-at-value {
+ font-family: var(--vscode-editor-font-family);
+ }
+
+ .example-item {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 2px;
+ border: 1px solid var(--vscode-panel-border);
+ background-color: var(--vscode-panel-background);
+ color: var(--vscode-foreground);
+ cursor: pointer;
+ text-align: left;
+ transition: background-color 100ms ease-in-out, border-color 100ms ease-in-out;
+ }
+
+ .example-item:hover {
+ background-color: var(--vscode-toolbar-hoverBackground);
+ }
+
+ .example-item.is-active {
+ border-color: var(--vscode-focusBorder);
+ background-color: var(--vscode-list-activeSelectionBackground);
+ color: var(--vscode-list-activeSelectionForeground);
+ }
+
+ .example-cron {
+ font-family: var(--vscode-editor-font-family);
+ font-size: 0.75rem;
+ white-space: nowrap;
+ }
+
+ .example-desc {
+ font-size: 0.75rem;
+ opacity: 0.85;
+ text-align: right;
+ }
+
+ .field-row {
+ display: grid;
+ grid-template-columns: 88px 96px 1fr;
+ gap: 8px;
+ align-items: center;
+ min-height: 28px;
+ margin-bottom: 6px;
+ padding: 6px 8px;
+ border-radius: 2px;
+ background-color: var(--vscode-panel-background);
+ box-shadow: inset 0 0 0 1px var(--vscode-panel-border);
+ }
+
+ .field-label {
+ font-size: 0.75rem;
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .field-range {
+ font-family: var(--vscode-editor-font-family);
+ font-size: 0.75rem;
+ opacity: 0.9;
+ }
+
+ .field-meaning {
+ font-size: 0.75rem;
+ opacity: 0.9;
+ }
+ `;
+
+ @state() private input = '*/5 * * * *';
+ @state() private humanReadable = '';
+ @state() private nextAt = 'β';
+ @state() private alert: AlertState | null = null;
+
+ @query('#cron-input') private inputArea!: HTMLTextAreaElement;
+
+ constructor() {
+ super();
+ this.evaluateExpression();
+ }
+
+ protected firstUpdated(): void {
+ if (this.inputArea) {
+ adjustTextareaHeight(this.inputArea);
+ setTimeout(() => this.inputArea?.focus(), 0);
+ }
+ }
+
+ protected renderTool() {
+ return html`
+
+
+ `;
+ }
+
+ private handleInput(event: Event): void {
+ const target = event.target as HTMLTextAreaElement;
+ this.input = target.value;
+ adjustTextareaHeight(this.inputArea);
+ this.evaluateExpression();
+ }
+
+ private clearAll(): void {
+ this.input = '';
+ this.humanReadable = 'Enter a cron expression to generate description.';
+ this.nextAt = 'β';
+ this.alert = null;
+
+ if (this.inputArea) {
+ this.inputArea.style.height = '28px';
+ }
+ }
+
+ private applyExample(expression: string): void {
+ this.input = expression;
+ this.evaluateExpression();
+ if (this.inputArea) {
+ this.inputArea.value = expression;
+ adjustTextareaHeight(this.inputArea);
+ }
+ }
+
+ private evaluateExpression(): void {
+ const expression = this.input.trim();
+ if (!expression) {
+ this.humanReadable = 'Enter a cron expression to generate description.';
+ this.nextAt = 'β';
+ this.alert = null;
+ return;
+ }
+
+ try {
+ const parsed = this.parseCronExpression(expression);
+ this.humanReadable = this.buildHumanReadable(parsed);
+
+ const nextRun = this.findNextRun(parsed, new Date());
+ if (nextRun) {
+ this.nextAt = this.formatDateTime(nextRun);
+ this.alert = null;
+ } else {
+ this.nextAt = 'Not found';
+ this.alert = {
+ type: 'warning',
+ message: 'No next execution found in the search window.'
+ };
+ }
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : 'Invalid cron expression.';
+ this.humanReadable = 'Unable to parse expression.';
+ this.nextAt = 'β';
+ this.alert = {
+ type: 'error',
+ message
+ };
+ }
+ }
+
+ private parseCronExpression(expression: string): ParsedCron {
+ const normalized = this.expandAlias(expression);
+ const parts = normalized.trim().split(/\s+/);
+
+ if (parts.length !== 5) {
+ throw new Error('Cron expression must contain exactly 5 fields.');
+ }
+
+ const minute = this.parseField(parts[0], 0, 59);
+ const hour = this.parseField(parts[1], 0, 23);
+ const dayOfMonth = this.parseField(parts[2], 1, 31);
+ const month = this.parseField(parts[3], 1, 12, CrontabGenerator.MONTH_NAME_MAP);
+ const dayOfWeek = this.parseField(parts[4], 0, 7, CrontabGenerator.WEEKDAY_NAME_MAP, true);
+
+ return {
+ rawFields: [parts[0], parts[1], parts[2], parts[3], parts[4]],
+ minute,
+ hour,
+ dayOfMonth,
+ month,
+ dayOfWeek
+ };
+ }
+
+ private expandAlias(expression: string): string {
+ const alias = expression.trim().toLowerCase();
+ const aliasMap: Record = {
+ '@yearly': '0 0 1 1 *',
+ '@annually': '0 0 1 1 *',
+ '@monthly': '0 0 1 * *',
+ '@weekly': '0 0 * * 0',
+ '@daily': '0 0 * * *',
+ '@midnight': '0 0 * * *',
+ '@hourly': '0 * * * *'
+ };
+
+ if (alias.startsWith('@')) {
+ if (!Object.prototype.hasOwnProperty.call(aliasMap, alias)) {
+ throw new Error(`Unsupported cron alias: ${expression}`);
+ }
+ return aliasMap[alias];
+ }
+
+ return expression;
+ }
+
+ private parseField(
+ raw: string,
+ min: number,
+ max: number,
+ nameMap?: Record,
+ normalizeSunday = false
+ ): ParsedField {
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ throw new Error('Empty cron field detected.');
+ }
+
+ const segments = trimmed.split(',');
+ const collected: number[] = [];
+
+ for (const segment of segments) {
+ const parsedSegment = this.parseSegment(segment.trim(), min, max, nameMap);
+ collected.push(...parsedSegment);
+ }
+
+ const normalized = normalizeSunday
+ ? collected.map((value) => (value === 7 ? 0 : value))
+ : collected;
+
+ const uniqueSorted = Array.from(new Set(normalized)).sort((a, b) => a - b);
+ if (uniqueSorted.length === 0) {
+ throw new Error(`Invalid field: "${raw}"`);
+ }
+
+ const expected = normalizeSunday
+ ? [0, 1, 2, 3, 4, 5, 6]
+ : this.buildRange(min, max);
+
+ return {
+ raw: trimmed,
+ values: uniqueSorted,
+ set: new Set(uniqueSorted),
+ any: this.sameNumberList(uniqueSorted, expected)
+ };
+ }
+
+ private parseSegment(
+ segment: string,
+ min: number,
+ max: number,
+ nameMap?: Record
+ ): number[] {
+ const stepParts = segment.split('/');
+ if (stepParts.length > 2) {
+ throw new Error(`Invalid step syntax: "${segment}"`);
+ }
+
+ if (stepParts.length === 2) {
+ const base = stepParts[0];
+ const step = this.parsePositiveInteger(stepParts[1], 'step');
+
+ let start = min;
+ let end = max;
+
+ if (base !== '*') {
+ if (base.includes('-')) {
+ const [startToken, endToken] = base.split('-');
+ if (!startToken || !endToken) {
+ throw new Error(`Invalid range: "${segment}"`);
+ }
+
+ start = this.parseValue(startToken, min, max, nameMap);
+ end = this.parseValue(endToken, min, max, nameMap);
+ if (start > end) {
+ throw new Error(`Range start must be <= end in "${segment}"`);
+ }
+ } else {
+ start = this.parseValue(base, min, max, nameMap);
+ end = max;
+ }
+ }
+
+ return this.buildSteppedRange(start, end, step);
+ }
+
+ if (segment === '*') {
+ return this.buildRange(min, max);
+ }
+
+ if (segment.includes('-')) {
+ const [startToken, endToken] = segment.split('-');
+ if (!startToken || !endToken) {
+ throw new Error(`Invalid range: "${segment}"`);
+ }
+
+ const start = this.parseValue(startToken, min, max, nameMap);
+ const end = this.parseValue(endToken, min, max, nameMap);
+ if (start > end) {
+ throw new Error(`Range start must be <= end in "${segment}"`);
+ }
+
+ return this.buildRange(start, end);
+ }
+
+ return [this.parseValue(segment, min, max, nameMap)];
+ }
+
+ private parsePositiveInteger(token: string, label: string): number {
+ if (!/^\d+$/.test(token)) {
+ throw new Error(`Invalid ${label}: "${token}"`);
+ }
+
+ const value = Number.parseInt(token, 10);
+ if (value <= 0) {
+ throw new Error(`${label} must be greater than 0.`);
+ }
+
+ return value;
+ }
+
+ private parseValue(
+ token: string,
+ min: number,
+ max: number,
+ nameMap?: Record
+ ): number {
+ const upperToken = token.toUpperCase();
+ let value: number | undefined;
+
+ if (nameMap && Object.prototype.hasOwnProperty.call(nameMap, upperToken)) {
+ value = nameMap[upperToken];
+ } else if (/^\d+$/.test(token)) {
+ value = Number.parseInt(token, 10);
+ }
+
+ if (value === undefined) {
+ throw new Error(`Invalid token: "${token}"`);
+ }
+
+ if (value < min || value > max) {
+ throw new Error(`Value out of range: "${token}" (allowed ${min}-${max})`);
+ }
+
+ return value;
+ }
+
+ private buildRange(start: number, end: number): number[] {
+ const length = end - start + 1;
+ return Array.from({ length }, (_, index) => start + index);
+ }
+
+ private buildSteppedRange(start: number, end: number, step: number): number[] {
+ const values: number[] = [];
+ for (let value = start; value <= end; value += step) {
+ values.push(value);
+ }
+ return values;
+ }
+
+ private sameNumberList(a: number[], b: number[]): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ for (let index = 0; index < a.length; index += 1) {
+ if (a[index] !== b[index]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private buildHumanReadable(parsed: ParsedCron): string {
+ const [minuteRaw, hourRaw] = parsed.rawFields;
+ const timePart = this.describeTimePart(minuteRaw, hourRaw);
+ const datePart = this.describeDatePart(parsed);
+
+ if (!datePart) {
+ return timePart;
+ }
+
+ return `${timePart.slice(0, -1)} ${datePart}.`;
+ }
+
+ private describeTimePart(minuteRaw: string, hourRaw: string): string {
+ const minuteIsSingle = this.isSingleNumber(minuteRaw);
+ const hourIsSingle = this.isSingleNumber(hourRaw);
+
+ if (minuteIsSingle && hourIsSingle) {
+ const minute = Number.parseInt(minuteRaw, 10);
+ const hour = Number.parseInt(hourRaw, 10);
+ return `At ${this.pad2(hour)}:${this.pad2(minute)}.`;
+ }
+
+ if (minuteIsSingle) {
+ const minute = Number.parseInt(minuteRaw, 10);
+ return `At minute ${minute} past ${this.describeHourExpression(hourRaw)}.`;
+ }
+
+ if (hourIsSingle) {
+ const hour = Number.parseInt(hourRaw, 10);
+ return `At ${this.describeMinuteExpression(minuteRaw)} past hour ${hour}.`;
+ }
+
+ if (minuteRaw === '*' && hourRaw === '*') {
+ return 'Every minute.';
+ }
+
+ if (minuteRaw === '*') {
+ return `Every minute of ${this.describeHourExpression(hourRaw)}.`;
+ }
+
+ if (hourRaw === '*') {
+ return `At ${this.describeMinuteExpression(minuteRaw)} of every hour.`;
+ }
+
+ return `At ${this.describeMinuteExpression(minuteRaw)} of ${this.describeHourExpression(hourRaw)}.`;
+ }
+
+ private describeDatePart(parsed: ParsedCron): string {
+ const clauses: string[] = [];
+
+ if (!parsed.month.any) {
+ clauses.push(`in ${this.describeMonths(parsed.month.values)}`);
+ }
+
+ if (!parsed.dayOfMonth.any && !parsed.dayOfWeek.any) {
+ clauses.push(
+ `when day-of-month matches "${parsed.dayOfMonth.raw}" or weekday is ${this.describeWeekdays(parsed.dayOfWeek.values)}`
+ );
+ } else if (!parsed.dayOfMonth.any) {
+ const dayDesc = this.describeDayOfMonth(parsed.dayOfMonth.raw);
+ const article = dayDesc.startsWith('every') ? '' : 'the ';
+ clauses.push(`on ${article}${dayDesc} of the month`);
+ } else if (!parsed.dayOfWeek.any) {
+ clauses.push(`on ${this.describeWeekdays(parsed.dayOfWeek.values)}`);
+ }
+
+ return clauses.join(' ');
+ }
+
+ private describeMinuteExpression(raw: string): string {
+ return this.describeUnitExpression(raw, 'minute');
+ }
+
+ private describeHourExpression(raw: string): string {
+ return this.describeUnitExpression(raw, 'hour');
+ }
+
+ private describeUnitExpression(raw: string, unit: 'minute' | 'hour'): string {
+ if (raw === '*') {
+ return `every ${unit}`;
+ }
+
+ const wildcardStep = raw.match(/^\*\/(\d+)$/);
+ if (wildcardStep) {
+ const step = Number.parseInt(wildcardStep[1], 10);
+ return `every ${step} ${unit}`;
+ }
+
+ const rangeStep = raw.match(/^(\d+)-(\d+)\/(\d+)$/);
+ if (rangeStep) {
+ const start = Number.parseInt(rangeStep[1], 10);
+ const end = Number.parseInt(rangeStep[2], 10);
+ const step = Number.parseInt(rangeStep[3], 10);
+ if (unit === 'hour') {
+ return `every ${step} ${unit} from ${this.pad2(start)}:00 through ${this.pad2(end)}:00`;
+ }
+ return `every ${step} ${unit} from ${start} through ${end}`;
+ }
+
+ const range = raw.match(/^(\d+)-(\d+)$/);
+ if (range) {
+ const start = Number.parseInt(range[1], 10);
+ const end = Number.parseInt(range[2], 10);
+ if (unit === 'hour') {
+ return `every ${unit} from ${this.pad2(start)}:00 through ${this.pad2(end)}:00`;
+ }
+ return `every ${unit} from ${start} through ${end}`;
+ }
+
+ if (raw.includes(',')) {
+ const values = raw.split(',').map((part) => part.trim());
+ return `${unit}s ${this.joinWithAnd(values)}`;
+ }
+
+ if (this.isSingleNumber(raw)) {
+ return `${unit} ${Number.parseInt(raw, 10)}`;
+ }
+
+ return `${unit} pattern "${raw}"`;
+ }
+
+ private describeDayOfMonth(raw: string): string {
+ if (this.isSingleNumber(raw)) {
+ const dayNum = Number.parseInt(raw, 10);
+ return this.toOrdinal(dayNum);
+ }
+
+ const wildcardStep = raw.match(/^\*\/(\d+)$/);
+ if (wildcardStep) {
+ const step = Number.parseInt(wildcardStep[1], 10);
+ return `every ${this.toOrdinal(step)} day`;
+ }
+
+ const rangeStep = raw.match(/^(\d+)-(\d+)\/(\d+)$/);
+ if (rangeStep) {
+ const start = Number.parseInt(rangeStep[1], 10);
+ const end = Number.parseInt(rangeStep[2], 10);
+ const step = Number.parseInt(rangeStep[3], 10);
+ return `every ${this.toOrdinal(step)} day from ${start} through ${end}`;
+ }
+
+ const range = raw.match(/^(\d+)-(\d+)$/);
+ if (range) {
+ return `${range[1]} through ${range[2]}`;
+ }
+
+ if (raw.includes(',')) {
+ return this.joinWithAnd(raw.split(',').map((part) => part.trim()));
+ }
+
+ return raw;
+ }
+
+ private describeMonths(values: number[]): string {
+ if (values.length >= 12) {
+ return 'every month';
+ }
+
+ const labels = values.map((month) => CrontabGenerator.MONTH_LABELS[month - 1]);
+ return this.joinWithAnd(labels);
+ }
+
+ private describeWeekdays(values: number[]): string {
+ if (values.length >= 7) {
+ return 'every day of the week';
+ }
+
+ const labels = values.map((day) => CrontabGenerator.WEEKDAY_LABELS[day]);
+ return this.joinWithAnd(labels);
+ }
+
+ private findNextRun(parsed: ParsedCron, from: Date): Date | null {
+ const candidate = new Date(from.getTime());
+ candidate.setSeconds(0, 0);
+ candidate.setMinutes(candidate.getMinutes() + 1);
+
+ const maxIterations = 4_300_000;
+
+ for (let index = 0; index < maxIterations; index += 1) {
+ if (!parsed.month.set.has(candidate.getMonth() + 1)) {
+ this.jumpToNextAllowedMonth(candidate, parsed.month.values);
+ continue;
+ }
+
+ if (!this.matchesDay(candidate, parsed)) {
+ candidate.setDate(candidate.getDate() + 1);
+ candidate.setHours(0, 0, 0, 0);
+ continue;
+ }
+
+ if (!parsed.hour.set.has(candidate.getHours())) {
+ const nextHour = this.nextGreaterValue(parsed.hour.values, candidate.getHours());
+ if (nextHour === undefined) {
+ candidate.setDate(candidate.getDate() + 1);
+ candidate.setHours(parsed.hour.values[0], 0, 0, 0);
+ } else {
+ candidate.setHours(nextHour, 0, 0, 0);
+ }
+ continue;
+ }
+
+ if (!parsed.minute.set.has(candidate.getMinutes())) {
+ const nextMinute = this.nextGreaterValue(parsed.minute.values, candidate.getMinutes());
+ if (nextMinute === undefined) {
+ candidate.setHours(candidate.getHours() + 1, parsed.minute.values[0], 0, 0);
+ } else {
+ candidate.setMinutes(nextMinute, 0, 0);
+ }
+ continue;
+ }
+
+ return candidate;
+ }
+
+ return null;
+ }
+
+ private matchesDay(date: Date, parsed: ParsedCron): boolean {
+ const dayOfMonth = date.getDate();
+ const dayOfWeek = date.getDay();
+
+ const domMatch = parsed.dayOfMonth.set.has(dayOfMonth);
+ const dowMatch = parsed.dayOfWeek.set.has(dayOfWeek);
+
+ if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) {
+ return true;
+ }
+
+ if (parsed.dayOfMonth.any) {
+ return dowMatch;
+ }
+
+ if (parsed.dayOfWeek.any) {
+ return domMatch;
+ }
+
+ return domMatch || dowMatch;
+ }
+
+ private jumpToNextAllowedMonth(date: Date, allowedMonths: number[]): void {
+ const currentMonth = date.getMonth() + 1;
+ const nextMonth = allowedMonths.find((month) => month > currentMonth);
+
+ if (nextMonth === undefined) {
+ date.setFullYear(date.getFullYear() + 1);
+ date.setMonth(allowedMonths[0] - 1, 1);
+ } else {
+ date.setMonth(nextMonth - 1, 1);
+ }
+
+ date.setHours(0, 0, 0, 0);
+ }
+
+ private nextGreaterValue(values: number[], current: number): number | undefined {
+ return values.find((value) => value > current);
+ }
+
+ private isSingleNumber(value: string): boolean {
+ return /^\d+$/.test(value);
+ }
+
+ private toOrdinal(value: number): string {
+ const mod100 = value % 100;
+ if (mod100 >= 11 && mod100 <= 13) {
+ return `${value}th`;
+ }
+
+ const mod10 = value % 10;
+ if (mod10 === 1) return `${value}st`;
+ if (mod10 === 2) return `${value}nd`;
+ if (mod10 === 3) return `${value}rd`;
+ return `${value}th`;
+ }
+
+ private joinWithAnd(values: string[]): string {
+ if (values.length === 0) return '';
+ if (values.length === 1) return values[0];
+ if (values.length === 2) return `${values[0]} and ${values[1]}`;
+ return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`;
+ }
+
+ private formatDateTime(date: Date): string {
+ const year = date.getFullYear();
+ const month = this.pad2(date.getMonth() + 1);
+ const day = this.pad2(date.getDate());
+ const hour = this.pad2(date.getHours());
+ const minute = this.pad2(date.getMinutes());
+ const second = this.pad2(date.getSeconds());
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+ }
+
+ private pad2(value: number): string {
+ return String(value).padStart(2, '0');
+ }
+}
\ No newline at end of file
diff --git a/src/tools/components/cubic-bezier/CubicBezier.ts b/src/tools/components/cubic-bezier/CubicBezier.ts
index 61bdd7c..533e4b7 100644
--- a/src/tools/components/cubic-bezier/CubicBezier.ts
+++ b/src/tools/components/cubic-bezier/CubicBezier.ts
@@ -2,7 +2,7 @@ import { html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { BaseTool } from '../../base/BaseTool';
import { renderCopyButton } from '../../../utils/util';
-import BezierEasing from 'bezier-easing';
+import { createCubicBezierEasing } from './cubicBezierUtils';
@customElement('cubic-bezier')
export class CubicBezier extends BaseTool {
@@ -13,8 +13,8 @@ export class CubicBezier extends BaseTool {
@state() private isCopied = false;
@state() private animationId: number | null = null;
@state() private linearAnimationId: number | null = null;
- @state() private easing: any = null;
- @state() private linearEasing = BezierEasing(0, 0, 1, 1);
+ @state() private easing: ((x: number) => number) | null = null;
+ @state() private linearEasing = createCubicBezierEasing(0, 0, 1, 1);
@state() private isDragging = false;
@query('.bezier-line') private bezierLine!: HTMLElement;
@@ -206,11 +206,10 @@ export class CubicBezier extends BaseTool {
private updateEasing() {
try {
- this.easing = BezierEasing(this.x1, this.y1, this.x2, this.y2);
+ this.easing = createCubicBezierEasing(this.x1, this.y1, this.x2, this.y2);
} catch (e) {
- console.error("Invalid bezier values:", e);
- // Use a fallback if values are invalid
- this.easing = BezierEasing(0.42, 0, 0.58, 1);
+ console.error('Invalid bezier values:', e);
+ this.easing = createCubicBezierEasing(0.42, 0, 0.58, 1);
}
}
diff --git a/src/tools/components/cubic-bezier/cubicBezierUtils.ts b/src/tools/components/cubic-bezier/cubicBezierUtils.ts
new file mode 100644
index 0000000..7df438e
--- /dev/null
+++ b/src/tools/components/cubic-bezier/cubicBezierUtils.ts
@@ -0,0 +1,137 @@
+/**
+ * Cubic Bezier easing solver
+ * --------------------------
+ * For animation easing, input is x (time progress), and output is y (eased progress).
+ * So for each x:
+ * 1) solve Bx(t) = x --> find t
+ * 2) compute y = By(t)
+ *
+ * Solve Bx(t) = x with Newton-Raphson for speed.
+ * If Newton is unstable (small slope / out-of-range / poor convergence), fallback to binary subdivision (bisection).
+ */
+
+export type EasingFunction = (x: number) => number;
+
+const NEWTON_ITERATIONS = 8;
+const NEWTON_MIN_SLOPE = 1e-7;
+const SUBDIVISION_MAX_ITERATIONS = 12;
+const SUBDIVISION_PRECISION = 1e-7;
+
+function clamp01(v: number): number {
+ if (v <= 0) return 0;
+ if (v >= 1) return 1;
+ return v;
+}
+
+/**
+ * Cubic bezier polynomial coefficients for:
+ * B(t) = ((A * t + B) * t + C) * t
+ *
+ * Derived from control points with fixed endpoints:
+ * P0 = 0, P1 = a1, P2 = a2, P3 = 1
+ */
+function A(a1: number, a2: number): number {
+ return 1 - 3 * a2 + 3 * a1;
+}
+
+function B(a1: number, a2: number): number {
+ return 3 * a2 - 6 * a1;
+}
+
+function C(a1: number): number {
+ return 3 * a1;
+}
+
+function calcBezier(t: number, a1: number, a2: number): number {
+ return ((A(a1, a2) * t + B(a1, a2)) * t + C(a1)) * t;
+}
+
+/**
+ * First derivative dB/dt at t.
+ * Used by Newton-Raphson:
+ * t_{n+1} = t_n - (B(t_n) - x) / B'(t_n)
+ */
+function getSlope(t: number, a1: number, a2: number): number {
+ return 3 * A(a1, a2) * t * t + 2 * B(a1, a2) * t + C(a1);
+}
+
+/**
+ * Robust fallback root-finding by bisection.
+ * Finds t in [a,b] where calcBezier(t, x1, x2) ~= x.
+ */
+function binarySubdivide(x: number, a: number, b: number, x1: number, x2: number): number {
+ let currentT = 0;
+ let currentX = 0;
+
+ for (let i = 0; i < SUBDIVISION_MAX_ITERATIONS; i++) {
+ currentT = a + (b - a) / 2;
+ currentX = calcBezier(currentT, x1, x2) - x;
+
+ if (Math.abs(currentX) <= SUBDIVISION_PRECISION) {
+ return currentT;
+ }
+
+ if (currentX > 0) {
+ b = currentT;
+ } else {
+ a = currentT;
+ }
+ }
+
+ return currentT;
+}
+
+function newtonRaphsonIterate(x: number, guessT: number, x1: number, x2: number): number {
+ let t = guessT;
+
+ for (let i = 0; i < NEWTON_ITERATIONS; i++) {
+ const slope = getSlope(t, x1, x2);
+
+ if (Math.abs(slope) < NEWTON_MIN_SLOPE) {
+ return t;
+ }
+
+ const currentX = calcBezier(t, x1, x2) - x;
+ t -= currentX / slope;
+ }
+
+ return t;
+}
+
+/**
+ * Create easing function equivalent to CSS cubic-bezier(x1, y1, x2, y2).
+ * Solves x(t)=x with Newton-Raphson, with binary subdivision fallback.
+ */
+export function createCubicBezierEasing(x1: number, y1: number, x2: number, y2: number): EasingFunction {
+ if (![x1, y1, x2, y2].every(Number.isFinite)) {
+ throw new Error('Invalid cubic-bezier control points');
+ }
+
+ // Clamp for resilience (UI already enforces this).
+ const cx1 = clamp01(x1);
+ const cx2 = clamp01(x2);
+
+ return (x: number): number => {
+ const clampedX = clamp01(x);
+
+ if (clampedX === 0) return 0;
+ if (clampedX === 1) return 1;
+
+ let t = clampedX; // Initial guess
+
+ // 1) Try Newton first
+ t = newtonRaphsonIterate(clampedX, t, cx1, cx2);
+
+ // 2) If invalid or not accurate enough, fallback to bisection.
+ if (!Number.isFinite(t) || t < 0 || t > 1) {
+ t = binarySubdivide(clampedX, 0, 1, cx1, cx2);
+ } else {
+ const error = Math.abs(calcBezier(t, cx1, cx2) - clampedX);
+ if (error > SUBDIVISION_PRECISION) {
+ t = binarySubdivide(clampedX, 0, 1, cx1, cx2);
+ }
+ }
+
+ return calcBezier(t, y1, y2);
+ };
+}
\ No newline at end of file
diff --git a/src/tools/components/data-format-convertor/DataFormatConvertorLogic.ts b/src/tools/components/data-format-convertor/DataFormatConvertorLogic.ts
index 4522d94..4b36c95 100644
--- a/src/tools/components/data-format-convertor/DataFormatConvertorLogic.ts
+++ b/src/tools/components/data-format-convertor/DataFormatConvertorLogic.ts
@@ -1,47 +1,79 @@
import * as YAML from 'js-yaml';
-import { XMLParser, XMLBuilder } from 'fast-xml-parser';
-import * as TOML from 'toml';
-import { jsonToToml } from './tomlUtils';
+import { XMLBuilder, XMLParser } from 'fast-xml-parser';
+import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
let currentFormatFrom = 'json';
let currentFormatTo = 'yaml';
let currentIndentation = 2;
let conversionListener: { dispose: () => void } | null = null;
-function toJSON(input: string, sourceFormat: string): any {
+function normalizeForToml(value: unknown): unknown {
+ if (value === null || value === undefined) {
+ return 'null';
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(item => normalizeForToml(item));
+ }
+
+ if (value instanceof Date) {
+ return value;
+ }
+
+ if (typeof value === 'object') {
+ const record = value as Record;
+ const normalized: Record = {};
+ for (const [key, item] of Object.entries(record)) {
+ normalized[key] = normalizeForToml(item);
+ }
+ return normalized;
+ }
+
+ return value;
+}
+
+function toJSON(input: string, sourceFormat: string): unknown {
switch (sourceFormat) {
case 'json':
return JSON.parse(input);
case 'yaml':
return YAML.load(input);
- case 'xml':
+ case 'xml': {
const parser = new XMLParser({
ignoreAttributes: false,
- attributeNamePrefix: "@_"
+ attributeNamePrefix: '@_'
});
return parser.parse(input);
+ }
case 'toml':
- return TOML.parse(input);
+ return parseToml(input);
default:
throw new Error(`Unsupported source format: ${sourceFormat}`);
}
}
-function fromJSON(jsonObj: any, targetFormat: string): string {
+function fromJSON(jsonObj: unknown, targetFormat: string): string {
switch (targetFormat) {
case 'json':
return JSON.stringify(jsonObj, null, currentIndentation);
case 'yaml':
return YAML.dump(jsonObj, { lineWidth: -1, indent: currentIndentation });
- case 'xml':
+ case 'xml': {
const builder = new XMLBuilder({
format: true,
ignoreAttributes: false,
indentBy: ' '.repeat(currentIndentation)
});
return builder.build(jsonObj);
- case 'toml':
- return jsonToToml(jsonObj, { indent: ' '.repeat(currentIndentation) });
+ }
+ case 'toml': {
+ if (jsonObj === null || typeof jsonObj !== 'object' || Array.isArray(jsonObj)) {
+ throw new Error('TOML root must be an object/table.');
+ }
+
+ const normalized = normalizeForToml(jsonObj) as Record;
+ return stringifyToml(normalized);
+ }
default:
throw new Error(`Unsupported target format: ${targetFormat}`);
}
@@ -52,13 +84,12 @@ function performConversion(inputText: string, formatFrom: string, formatTo: stri
console.error('Editors not initialized');
return;
}
-
+
if (!inputText.trim()) {
outputEditor.setValue('');
return;
}
- // No conversion needed if formats are the same
if (formatFrom === formatTo) {
outputEditor.setValue(inputText);
return;
@@ -67,56 +98,60 @@ function performConversion(inputText: string, formatFrom: string, formatTo: stri
try {
const jsonObj = toJSON(inputText, formatFrom);
const result = fromJSON(jsonObj, formatTo);
-
outputEditor.setValue(result);
- } catch (error: any) {
+ } catch (error: unknown) {
+ const message = error instanceof Error ? error.message : String(error);
console.error('Error during conversion:', error);
- outputEditor.setValue(`Error: ${error.message}`);
-
+ outputEditor.setValue(`Error: ${message}`);
+
(window as any).vscode.postMessage({
type: 'error',
- value: `Conversion error: ${error.message}`
+ value: `Conversion error: ${message}`
});
}
}
-function throttle(func: (...args: any[]) => void, delay: number) {
+function throttle(func: (...args: T) => void, delay: number) {
let lastCall = 0;
- let timeout: NodeJS.Timeout | null = null;
+ let timeout: ReturnType | null = null;
- return function(...args: any[]) {
- const now = new Date().getTime();
+ return (...args: T) => {
+ const now = Date.now();
const timeSinceLastCall = now - lastCall;
-
+
if (timeSinceLastCall >= delay) {
lastCall = now;
- return func(...args);
- } else {
- clearTimeout(timeout!);
- timeout = setTimeout(() => {
- lastCall = new Date().getTime();
- func(...args);
- }, delay - timeSinceLastCall);
+ func(...args);
+ return;
+ }
+
+ if (timeout) {
+ clearTimeout(timeout);
}
+
+ timeout = setTimeout(() => {
+ lastCall = Date.now();
+ func(...args);
+ }, delay - timeSinceLastCall);
};
}
-const convertSmall = throttle((text) => {
+const convertSmall = throttle((text: string) => {
performConversion(text, currentFormatFrom, currentFormatTo);
}, 0);
-const convertLarge = throttle((text) => {
+const convertLarge = throttle((text: string) => {
performConversion(text, currentFormatFrom, currentFormatTo);
}, 800);
-function updateFormats(args: { formatFrom: string, formatTo: string, indentation?: number }) {
+function updateFormats(args: { formatFrom: string; formatTo: string; indentation?: number }) {
currentFormatFrom = args.formatFrom;
currentFormatTo = args.formatTo;
-
+
if (typeof args.indentation === 'number') {
currentIndentation = args.indentation;
}
-
+
const text = inputEditor.getValue();
if (text.length > 0) {
performConversion(text, currentFormatFrom, currentFormatTo);
@@ -125,7 +160,7 @@ function updateFormats(args: { formatFrom: string, formatTo: string, indentation
function updateIndentation(args: { indentation: number }) {
currentIndentation = args.indentation;
-
+
const text = inputEditor.getValue();
if (text.length > 0) {
performConversion(text, currentFormatFrom, currentFormatTo);
@@ -145,22 +180,22 @@ function setupRealTimeConversion() {
if (!inputEditor || !outputEditor) {
return;
}
-
+
if (conversionListener) {
conversionListener.dispose();
conversionListener = null;
}
-
+
conversionListener = inputEditor.onDidChangeModelContent(() => {
const text = inputEditor.getValue();
-
+
if (text.length > 1000) {
convertLarge(text);
} else {
convertSmall(text);
}
});
-
+
const initialText = inputEditor.getValue();
if (initialText.length > 0) {
performConversion(initialText, currentFormatFrom, currentFormatTo);
@@ -177,7 +212,7 @@ function initializeWhenReady() {
initializeWhenReady();
-function convert(args: { formatFrom: string, formatTo: string, indentation?: number }) {
+function convert(args: { formatFrom: string; formatTo: string; indentation?: number }) {
updateFormats(args);
}
diff --git a/src/tools/components/data-format-convertor/tomlUtils.ts b/src/tools/components/data-format-convertor/tomlUtils.ts
deleted file mode 100644
index c79d1ff..0000000
--- a/src/tools/components/data-format-convertor/tomlUtils.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-export function jsonToToml(obj: any, options: { indent?: string } = {}): string {
- const indent = options.indent || ' ';
- return convertObjectToToml(obj, '', indent);
-}
-
-function convertObjectToToml(obj: any, path: string = '', indent: string = ' ', depth: number = 0): string {
- if (obj === null || obj === undefined) {
- return '';
- }
-
- let result = '';
-
- // Process simple key/value pairs first
- const simpleEntries: [string, any][] = [];
- const objectEntries: [string, object][] = [];
- const arrayEntries: [string, any[]][] = [];
-
- Object.entries(obj).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) {
- // Array of objects needs special handling
- objectEntries.push([key, value]);
- } else {
- arrayEntries.push([key, value]);
- }
- } else if (typeof value === 'object' && value !== null) {
- objectEntries.push([key, value]);
- } else {
- simpleEntries.push([key, value]);
- }
- });
-
- simpleEntries.forEach(([key, value]) => {
- result += `${escapeKey(key)} = ${formatValue(value)}\n`;
- });
-
- arrayEntries.forEach(([key, value]) => {
- result += `${escapeKey(key)} = ${formatArray(value)}\n`;
- });
-
- if ((simpleEntries.length > 0 || arrayEntries.length > 0) && objectEntries.length > 0) {
- result += '\n';
- }
-
- objectEntries.forEach(([key, value], index) => {
- if (Array.isArray(value)) {
- value.forEach((item) => {
- if (typeof item === 'object' && item !== null) {
- const tableName = path ? `${path}.${key}` : key;
- result += `[[${tableName}]]\n`;
- result += convertObjectToToml(item, '', indent, depth + 1);
- result += '\n';
- }
- });
- } else {
- const newPath = path ? `${path}.${key}` : key;
- result += `[${newPath}]\n`;
- result += convertObjectToToml(value, '', indent, depth + 1);
- if (index < objectEntries.length - 1) {
- result += '\n';
- }
- }
- });
-
- return result;
-}
-
-function escapeKey(key: string): string {
- // If the key contains spaces or special characters, wrap it in quotes
- if (/[^A-Za-z0-9_-]/.test(key)) {
- return `"${key.replace(/"/g, '\\"')}"`;
- }
- return key;
-}
-
-function formatValue(value: any): string {
- if (value === null || value === undefined) {
- return 'null';
- }
-
- const type = typeof value;
-
- switch (type) {
- case 'string':
- // Use triple quotes for multiline strings
- if (value.includes('\n')) {
- return `"""\n${value}\n"""`;
- }
- return `"${value.replace(/"/g, '\\"')}"`;
-
- case 'number':
- case 'boolean':
- return String(value);
-
- case 'object':
- if (value instanceof Date) {
- return value.toISOString();
- }
- return 'null';
-
- default:
- return 'null';
- }
-}
-
-function formatArray(arr: any[]): string {
- if (arr.length === 0) {
- return '[]';
- }
-
- // Check if all items are of the same primitive type
- const firstType = typeof arr[0];
- const allSameType = arr.every(item => typeof item === firstType &&
- (firstType !== 'object' || item === null));
-
- // Simple, single-line array for primitive values
- if (allSameType && firstType !== 'object') {
- const items = arr.map(item => formatValue(item)).join(', ');
- return `[${items}]`;
- }
-
- // Multi-line array for mixed types or complex values
- const items = arr.map(item => formatValue(item)).join(',\n ');
- return `[\n ${items}\n]`;
-}
\ No newline at end of file
diff --git a/src/tools/components/data-unit-convertor/DataUnitConvertor.ts b/src/tools/components/data-unit-convertor/DataUnitConvertor.ts
index 67df254..ed9fd09 100644
--- a/src/tools/components/data-unit-convertor/DataUnitConvertor.ts
+++ b/src/tools/components/data-unit-convertor/DataUnitConvertor.ts
@@ -1,5 +1,5 @@
import { html, css } from 'lit';
-import { customElement, state } from 'lit/decorators.js';
+import { customElement, state, query } from 'lit/decorators.js';
import { BaseTool } from '../../base/BaseTool';
@customElement('data-unit-convertor')
@@ -10,6 +10,12 @@ export class DataUnitConvertor extends BaseTool {
@state() private conversions: { unit: string; bitValue: string; bitLabel: string; byteValue: string; byteLabel: string }[] = [];
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
+ @query('#input') inputElement!: HTMLInputElement;
+
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
`;
@@ -67,6 +73,7 @@ export class DataUnitConvertor extends BaseTool {
this.inputElement?.focus(), 0);
}
private styles = css`
diff --git a/src/tools/components/jwt-inspector/JwtInspector.ts b/src/tools/components/jwt-inspector/JwtInspector.ts
index 054e918..3f4722b 100644
--- a/src/tools/components/jwt-inspector/JwtInspector.ts
+++ b/src/tools/components/jwt-inspector/JwtInspector.ts
@@ -22,18 +22,22 @@ export class JwtInspector extends BaseTool {
@state() private header: Record
= {};
@state() private payload: Record = {};
- @query('#input') inputArea!: HTMLTextAreaElement;
- @query('#secretKey') secretKeyArea!: HTMLTextAreaElement;
-
- constructor() {
- super();
- }
+ @query('#input') inputElement!: HTMLTextAreaElement;
+ @query('#secretKey') secretKeyElement!: HTMLTextAreaElement;
async connectedCallback() {
super.connectedCallback();
await this.decodeJwt();
}
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
+ constructor() {
+ super();
+ }
+
private styles = css`
${BaseTool.styles}
@@ -315,9 +319,8 @@ export class JwtInspector extends BaseTool {
this.alertInput = null;
this.verificationStatus = 'none';
- const inputTextarea = this.querySelector('#input') as HTMLTextAreaElement;
- if (inputTextarea) {
- inputTextarea.style.height = '28px';
+ if (this.inputElement) {
+ this.inputElement.style.height = '28px';
}
this.requestUpdate();
@@ -328,9 +331,8 @@ export class JwtInspector extends BaseTool {
this.alertSecretKey = null;
this.verificationStatus = 'none';
- const secretKeyTextarea = this.querySelector('#secretKey') as HTMLTextAreaElement;
- if (secretKeyTextarea) {
- secretKeyTextarea.style.height = '28px';
+ if (this.secretKeyElement) {
+ this.secretKeyElement.style.height = '28px';
}
this.requestUpdate();
diff --git a/src/tools/components/markdown-table-builder/MarkdownTableBuilder.ts b/src/tools/components/markdown-table-builder/MarkdownTableBuilder.ts
index 4aae167..70b9b92 100644
--- a/src/tools/components/markdown-table-builder/MarkdownTableBuilder.ts
+++ b/src/tools/components/markdown-table-builder/MarkdownTableBuilder.ts
@@ -16,7 +16,7 @@ export class MarkdownTableBuilder extends BaseTool {
@state() private isCopied = false;
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
- @query('#output') private outputEl!: HTMLTextAreaElement;
+ @query('#output') private outputElement!: HTMLTextAreaElement;
connectedCallback() {
super.connectedCallback();
@@ -191,8 +191,8 @@ export class MarkdownTableBuilder extends BaseTool {
this.output = markdown;
setTimeout(() => {
- if (this.outputEl) {
- adjustTextareaHeight(this.outputEl);
+ if (this.outputElement) {
+ adjustTextareaHeight(this.outputElement);
}
}, 0);
}
diff --git a/src/tools/components/number-base-convertor/NumberBaseConvertor.ts b/src/tools/components/number-base-convertor/NumberBaseConvertor.ts
index 7105bac..968ecc0 100644
--- a/src/tools/components/number-base-convertor/NumberBaseConvertor.ts
+++ b/src/tools/components/number-base-convertor/NumberBaseConvertor.ts
@@ -19,7 +19,16 @@ export class NumberBaseConvertor extends BaseTool {
@state() private copiedBase: string | null = null;
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
- @query('#input') inputTextarea!: HTMLTextAreaElement;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.processInput();
+ }
+
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
private styles = css`
${BaseTool.styles}
@@ -59,11 +68,6 @@ export class NumberBaseConvertor extends BaseTool {
}
`;
- connectedCallback() {
- super.connectedCallback();
- this.processInput();
- }
-
protected renderTool() {
return html`
@@ -288,9 +292,8 @@ export class NumberBaseConvertor extends BaseTool {
this.alert = null;
this.copiedBase = null;
- const inputTextarea = this.querySelector('#input') as HTMLTextAreaElement;
- if (inputTextarea) {
- inputTextarea.style.height = '28px';
+ if (this.inputElement) {
+ this.inputElement.style.height = '28px';
}
this.requestUpdate();
diff --git a/src/tools/components/qr-code-generator/QrCodeGenerator.ts b/src/tools/components/qr-code-generator/QrCodeGenerator.ts
index ae5df0a..5f66dd5 100644
--- a/src/tools/components/qr-code-generator/QrCodeGenerator.ts
+++ b/src/tools/components/qr-code-generator/QrCodeGenerator.ts
@@ -1,5 +1,5 @@
import { html, css } from 'lit';
-import { customElement, state } from 'lit/decorators.js';
+import { customElement, state, query } from 'lit/decorators.js';
import { BaseTool } from '../../base/BaseTool';
import { adjustTextareaHeight } from '../../../utils/util';
import * as QRCode from 'qrcode';
@@ -18,6 +18,12 @@ export class QrCodeGenerator extends BaseTool {
@state() private foregroundColor = '#000000';
@state() private maskPattern = 2;
+ @query('#input-link') inputElement!: HTMLTextAreaElement;
+
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
diff --git a/src/tools/components/rsa-encryption/RsaEncryption.ts b/src/tools/components/rsa-encryption/RsaEncryption.ts
index 07e92c0..e653025 100644
--- a/src/tools/components/rsa-encryption/RsaEncryption.ts
+++ b/src/tools/components/rsa-encryption/RsaEncryption.ts
@@ -33,6 +33,10 @@ export class RsaEncryption extends BaseTool {
@query('#file-input') private fileInput!: HTMLInputElement;
@query('#key-file-input') private keyFileInput!: HTMLInputElement;
+ firstUpdated() {
+ setTimeout(() => this.input?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
`;
diff --git a/src/tools/components/sha-hashing/ShaHashing.ts b/src/tools/components/sha-hashing/ShaHashing.ts
index ff49ccb..a2123ad 100644
--- a/src/tools/components/sha-hashing/ShaHashing.ts
+++ b/src/tools/components/sha-hashing/ShaHashing.ts
@@ -27,6 +27,10 @@ export class ShaHashing extends BaseTool {
@query('#output') private output!: HTMLTextAreaElement;
@query('#file-input') private fileInput!: HTMLInputElement;
+ firstUpdated() {
+ setTimeout(() => this.input?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
`;
diff --git a/src/tools/components/signature-verifier/SignatureVerifier.ts b/src/tools/components/signature-verifier/SignatureVerifier.ts
index 1e6fa48..ff4c1f5 100644
--- a/src/tools/components/signature-verifier/SignatureVerifier.ts
+++ b/src/tools/components/signature-verifier/SignatureVerifier.ts
@@ -40,6 +40,10 @@ export class SignatureVerifier extends BaseTool {
@query('#file-input') private fileInput!: HTMLInputElement;
@query('#key-file-input') private keyFileInput!: HTMLInputElement;
+ firstUpdated() {
+ setTimeout(() => this.input?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
`;
diff --git a/src/tools/components/slug-generator/SlugGenerator.ts b/src/tools/components/slug-generator/SlugGenerator.ts
index 9361cd6..a090ea5 100644
--- a/src/tools/components/slug-generator/SlugGenerator.ts
+++ b/src/tools/components/slug-generator/SlugGenerator.ts
@@ -15,18 +15,24 @@ export class SlugGenerator extends BaseTool {
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
@state() private isCopied = false;
- @query('#output') outputTextarea!: HTMLTextAreaElement;
-
- private styles = css`
- ${BaseTool.styles}
- /* Minimal local styling if needed. */
- `;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+ @query('#output') outputElement!: HTMLTextAreaElement;
connectedCallback() {
super.connectedCallback();
this.processInput();
}
+ firstUpdated() {
+ this.processInput();
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
+ private styles = css`
+ ${BaseTool.styles}
+ /* Minimal local styling if needed. */
+ `;
+
protected renderTool() {
return html`
@@ -188,9 +194,9 @@ export class SlugGenerator extends BaseTool {
this.output = '';
}
- if (this.outputTextarea) {
+ if (this.outputElement) {
await this.updateComplete;
- adjustTextareaHeight(this.outputTextarea);
+ adjustTextareaHeight(this.outputElement);
}
}
@@ -199,11 +205,8 @@ export class SlugGenerator extends BaseTool {
this.output = '';
this.alert = null;
- const inputTextarea = this.querySelector('#input') as HTMLTextAreaElement;
- const outputTextarea = this.querySelector('#output') as HTMLTextAreaElement;
-
- inputTextarea.style.height = `28px`;
- outputTextarea.style.height = `auto`;
+ this.inputElement.style.height = `28px`;
+ this.outputElement.style.height = `auto`;
this.requestUpdate();
}
}
\ No newline at end of file
diff --git a/src/tools/components/unicode-inspector/UnicodeInspector.ts b/src/tools/components/unicode-inspector/UnicodeInspector.ts
index b640799..5b268a1 100644
--- a/src/tools/components/unicode-inspector/UnicodeInspector.ts
+++ b/src/tools/components/unicode-inspector/UnicodeInspector.ts
@@ -1,5 +1,5 @@
import { html, css } from 'lit';
-import { customElement, state, property } from 'lit/decorators.js';
+import { customElement, state, property, query } from 'lit/decorators.js';
import { BaseTool } from '../../base/BaseTool';
import {
adjustTextareaHeight,
@@ -34,12 +34,18 @@ export class UnicodeInspector extends BaseTool {
@property({ type: Array }) formatOptions = FORMAT_OPTIONS;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+
constructor() {
super();
this.initUnicodeBlocks();
this.processInput();
}
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
diff --git a/src/tools/components/unix-path-convertor/UnixPathConvertor.ts b/src/tools/components/unix-path-convertor/UnixPathConvertor.ts
index cccdaf7..5cefd50 100644
--- a/src/tools/components/unix-path-convertor/UnixPathConvertor.ts
+++ b/src/tools/components/unix-path-convertor/UnixPathConvertor.ts
@@ -18,6 +18,10 @@ export class UnixPathConvertor extends BaseTool {
@query('#unix-input') unixPathTextarea!: HTMLTextAreaElement;
@query('#windows-input') windowsPathTextarea!: HTMLTextAreaElement;
+ firstUpdated() {
+ setTimeout(() => this.unixPathTextarea?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
/* Minimal local styling if needed. */
diff --git a/src/tools/components/url-encoder/UrlEncoder.ts b/src/tools/components/url-encoder/UrlEncoder.ts
index 4cf0e8d..681666a 100644
--- a/src/tools/components/url-encoder/UrlEncoder.ts
+++ b/src/tools/components/url-encoder/UrlEncoder.ts
@@ -9,13 +9,20 @@ import {
@customElement('url-encoder')
export class UrlEncoder extends BaseTool {
@state() private selectedMode: 'encode' | 'decode' = 'encode';
- @state() private input = '';
+ @state() private input = 'https://localhost:3000/naΓ―vetΓ©';
@state() private output = '';
@state() private alert: { type: 'error' | 'warning'; message: string } | null = null;
@state() private isCopied = false;
- @state() private preserveUrl = true;
+ @state() private encodePreserveUrl = true;
+ @state() private decodePreserveUrl = true;
- @query('#output') outputTextarea!: HTMLTextAreaElement;
+ @query('#input') inputElement!: HTMLTextAreaElement;
+ @query('#output') outputElement!: HTMLTextAreaElement;
+
+ firstUpdated() {
+ this.processInput();
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
private styles = css`
${BaseTool.styles}
@@ -23,6 +30,12 @@ export class UrlEncoder extends BaseTool {
`;
protected renderTool() {
+ const isEncode = this.selectedMode === 'encode';
+ const preserveCurrentMode = isEncode ? this.encodePreserveUrl : this.decodePreserveUrl;
+ const switchLabel = isEncode
+ ? 'Preserve URL structure'
+ : 'Fully decode URL structure';
+
return html`
@@ -56,6 +69,7 @@ export class UrlEncoder extends BaseTool {
+
+
${this.alert ? html`
- ${this.selectedMode === 'encode' ? html`
-
-
-
- ` : ''}
-
+
+
+
`;
}
@@ -127,8 +139,15 @@ export class UrlEncoder extends BaseTool {
this.processInput();
}
- private handleEncodingMethodChange(event: CustomEvent) {
- this.preserveUrl = event.detail.checked;
+ private handleMethodChange(event: CustomEvent) {
+ const checked = event.detail.checked as boolean;
+
+ if (this.selectedMode === 'encode') {
+ this.encodePreserveUrl = checked;
+ } else {
+ this.decodePreserveUrl = checked;
+ }
+
this.processInput();
}
@@ -173,15 +192,15 @@ export class UrlEncoder extends BaseTool {
this.output = '';
}
- if (this.outputTextarea) {
+ if (this.outputElement) {
await this.updateComplete;
- adjustTextareaHeight(this.outputTextarea);
+ adjustTextareaHeight(this.outputElement);
}
}
private encodeURL(input: string): string {
try {
- if (this.preserveUrl) {
+ if (this.encodePreserveUrl) {
return encodeURI(input);
} else {
return encodeURIComponent(input);
@@ -197,10 +216,10 @@ export class UrlEncoder extends BaseTool {
private decodeURL(input: string): string {
try {
- if (this.preserveUrl) {
- return decodeURI(input);
- } else {
+ if (this.decodePreserveUrl) {
return decodeURIComponent(input);
+ } else {
+ return decodeURI(input);
}
} catch (error) {
this.alert = {
diff --git a/src/tools/components/url-parser/UrlParser.ts b/src/tools/components/url-parser/UrlParser.ts
index 9634227..96b1fde 100644
--- a/src/tools/components/url-parser/UrlParser.ts
+++ b/src/tools/components/url-parser/UrlParser.ts
@@ -21,13 +21,17 @@ export class UrlParser extends BaseTool {
hash: ''
};
- @query('#input') inputArea!: HTMLTextAreaElement;
+ @query('#input') inputElement!: HTMLTextAreaElement;
constructor() {
super();
this.parseUrl();
}
+ firstUpdated() {
+ setTimeout(() => this.inputElement?.focus(), 0);
+ }
+
private styles = css`
${BaseTool.styles}
@@ -173,7 +177,7 @@ export class UrlParser extends BaseTool {
private handleInput(event: Event): void {
const target = event.target as HTMLTextAreaElement;
this.input = target.value;
- adjustTextareaHeight(this.inputArea);
+ adjustTextareaHeight(this.inputElement);
this.parseUrl();
}
@@ -284,7 +288,7 @@ export class UrlParser extends BaseTool {
this.params = newParams;
this.updateUrlFromParams();
- adjustTextareaHeight(this.inputArea);
+ adjustTextareaHeight(this.inputElement);
}
private removeParam(index: number): void {
@@ -328,7 +332,6 @@ export class UrlParser extends BaseTool {
}
this.input = url.toString();
- this.parseUrl();
this.alert = null;
} catch (error) {
this.alert = {
@@ -343,7 +346,7 @@ export class UrlParser extends BaseTool {
this.params = [{key: '', value: ''}];
this.resetUrlComponents();
this.alert = null;
- const inputTextarea = this.querySelector('#input') as HTMLTextAreaElement;
+ const inputTextarea = this.inputElement;
if (inputTextarea) {
inputTextarea.style.height = '28px';
}
diff --git a/src/tools/toolComponents.ts b/src/tools/toolComponents.ts
index 667da06..79e50ce 100644
--- a/src/tools/toolComponents.ts
+++ b/src/tools/toolComponents.ts
@@ -15,10 +15,12 @@ import './common/tooltip/Tooltip';
import './components/aes-encryption/AesEncryption';
import './components/ascii-encoder/AsciiEncoder';
import './components/base64-encoder/Base64Encoder';
+import './components/chmod-calculator/ChmodCalculator';
import './components/color-convertor/ColorConvertor';
import './components/color-mixer/ColorMixer';
import './components/color-palette/ColorPalette';
import './components/contrast-checker/ContrastChecker';
+import './components/crontab-generator/CrontabGenerator';
import './components/cubic-bezier/CubicBezier';
import './components/data-format-convertor/DataFormatConvertor';
import './components/datetime-convertor/DatetimeConvertor';