From 15e68ac2de1adb893e4620ba1aba44bb9a0dd4d7 Mon Sep 17 00:00:00 2001 From: "FUNGCHAN\\qwert" Date: Fri, 20 Mar 2026 04:11:39 +0800 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=90=9B=20|=20Fix=20incorrect=20non-?= =?UTF-8?q?ASCII=20text=20file=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base64-encoder/Base64Encoder.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/tools/components/base64-encoder/Base64Encoder.ts b/src/tools/components/base64-encoder/Base64Encoder.ts index db5970e..325a597 100644 --- a/src/tools/components/base64-encoder/Base64Encoder.ts +++ b/src/tools/components/base64-encoder/Base64Encoder.ts @@ -491,20 +491,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,11 +513,7 @@ 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); }); } @@ -567,6 +563,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) { From 072ad109feaf428d29faf7a3d349d25bcdb83ad8 Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Fri, 20 Mar 2026 22:02:13 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8=20|=20Update=20Base64=20decodin?= =?UTF-8?q?g=20to=20directly=20output=20text=20for=20text/plain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base64-encoder/Base64Encoder.ts | 81 ++++++++++--------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/tools/components/base64-encoder/Base64Encoder.ts b/src/tools/components/base64-encoder/Base64Encoder.ts index 325a597..5bca2e1 100644 --- a/src/tools/components/base64-encoder/Base64Encoder.ts +++ b/src/tools/components/base64-encoder/Base64Encoder.ts @@ -256,7 +256,12 @@ export class Base64Encoder extends BaseTool { const [header, content] = base64Data.split(','); 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); @@ -517,43 +522,6 @@ export class Base64Encoder extends BaseTool { }); } - 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(','); @@ -614,4 +582,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.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(); + } } \ No newline at end of file From c99d42772e78e11475bf575317e58eeb7e8dd39d Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Fri, 20 Mar 2026 23:01:22 +0800 Subject: [PATCH 03/16] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20|=20Improve=20data?= =?UTF-8?q?=20URI=20handling=20to=20validate=20format=20and=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base64-encoder/Base64Encoder.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/tools/components/base64-encoder/Base64Encoder.ts b/src/tools/components/base64-encoder/Base64Encoder.ts index 5bca2e1..f675cc1 100644 --- a/src/tools/components/base64-encoder/Base64Encoder.ts +++ b/src/tools/components/base64-encoder/Base64Encoder.ts @@ -253,7 +253,29 @@ 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]; if (this.decodedMimeType.startsWith('text/plain')) { From 057d9435c7039268e093d32253656f252f859355 Mon Sep 17 00:00:00 2001 From: "FUNGCHAN\\qwert" Date: Sat, 21 Mar 2026 03:55:13 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E2=9C=A8=20|=20Refactor=20MIME=20type=20?= =?UTF-8?q?detection=20and=20change=20default=20to=20octet-stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base64-encoder/Base64Encoder.ts | 115 +------------- .../components/base64-encoder/mimeUtils.ts | 140 ++++++++++++++++++ 2 files changed, 142 insertions(+), 113 deletions(-) create mode 100644 src/tools/components/base64-encoder/mimeUtils.ts diff --git a/src/tools/components/base64-encoder/Base64Encoder.ts b/src/tools/components/base64-encoder/Base64Encoder.ts index f675cc1..a0bbd95 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 { @@ -287,7 +288,7 @@ export class Base64Encoder extends BaseTool { } 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 @@ -361,118 +362,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(); } 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 From 36937ff3a9383029c39ae135e28452ff17095b08 Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Sat, 21 Mar 2026 22:15:00 +0800 Subject: [PATCH 05/16] =?UTF-8?q?=E2=9C=A8=20|=20Separate=20preservation?= =?UTF-8?q?=20settings=20for=20encoding=20and=20decoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/url-encoder/UrlEncoder.ts | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/tools/components/url-encoder/UrlEncoder.ts b/src/tools/components/url-encoder/UrlEncoder.ts index 4cf0e8d..3f0a6ea 100644 --- a/src/tools/components/url-encoder/UrlEncoder.ts +++ b/src/tools/components/url-encoder/UrlEncoder.ts @@ -9,11 +9,12 @@ 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; @@ -22,7 +23,17 @@ export class UrlEncoder extends BaseTool { /* Minimal local styling if needed. */ `; + firstUpdated() { + this.processInput(); + } + 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 +67,7 @@ export class UrlEncoder extends BaseTool {
+
+ +
+ + + +
+
+ + ${this.alert ? html` + + ` : ''} + +
+
Common Examples
+
+ ${CrontabGenerator.COMMON_EXAMPLES.map((example) => html` + + `)} +
+
+ +
+
Cron Format
+ ${CrontabGenerator.FIELD_INFO.map((info) => html` +
+ ${info.field} + ${info.range} + ${info.meaning} +
+ `)} +

+ Special characters: + * any, + , list, + - range, + / step. +

+
+ + `; + } + + 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) { + clauses.push(`on day-of-month ${this.describeDayOfMonth(parsed.dayOfMonth.raw)}`); + } 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 ${this.toOrdinal(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); + return `every ${this.toOrdinal(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); + 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)) { + return raw; + } + + 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/toolComponents.ts b/src/tools/toolComponents.ts index 667da06..fb774ce 100644 --- a/src/tools/toolComponents.ts +++ b/src/tools/toolComponents.ts @@ -19,6 +19,7 @@ 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'; From 7f06bcfc90ded22adf41d271d7ab08af4e260940 Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Sun, 29 Mar 2026 14:19:12 +0800 Subject: [PATCH 10/16] =?UTF-8?q?=E2=9C=A8=20|=20Improve=20human-readable?= =?UTF-8?q?=20cron=20descriptions=20and=20refine=20step=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crontab-generator/CrontabGenerator.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/tools/components/crontab-generator/CrontabGenerator.ts b/src/tools/components/crontab-generator/CrontabGenerator.ts index e2aa85a..d161ffb 100644 --- a/src/tools/components/crontab-generator/CrontabGenerator.ts +++ b/src/tools/components/crontab-generator/CrontabGenerator.ts @@ -608,7 +608,9 @@ export class CrontabGenerator extends BaseTool { `when day-of-month matches "${parsed.dayOfMonth.raw}" or weekday is ${this.describeWeekdays(parsed.dayOfWeek.values)}` ); } else if (!parsed.dayOfMonth.any) { - clauses.push(`on day-of-month ${this.describeDayOfMonth(parsed.dayOfMonth.raw)}`); + 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)}`); } @@ -632,7 +634,7 @@ export class CrontabGenerator extends BaseTool { const wildcardStep = raw.match(/^\*\/(\d+)$/); if (wildcardStep) { const step = Number.parseInt(wildcardStep[1], 10); - return `every ${this.toOrdinal(step)} ${unit}`; + return `every ${step} ${unit}`; } const rangeStep = raw.match(/^(\d+)-(\d+)\/(\d+)$/); @@ -640,13 +642,19 @@ export class CrontabGenerator extends BaseTool { 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)} ${unit} from ${start} through ${end}`; + 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}`; } @@ -664,7 +672,8 @@ export class CrontabGenerator extends BaseTool { private describeDayOfMonth(raw: string): string { if (this.isSingleNumber(raw)) { - return raw; + const dayNum = Number.parseInt(raw, 10); + return this.toOrdinal(dayNum); } const wildcardStep = raw.match(/^\*\/(\d+)$/); From 8f21ec4ecc0c8aa84de98a0e67318a635a5fcb89 Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Sun, 29 Mar 2026 16:24:04 +0800 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=90=9B=20|=20Fix=20param=20value=20?= =?UTF-8?q?not=20shown=20when=20value=20added=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/components/url-parser/UrlParser.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/components/url-parser/UrlParser.ts b/src/tools/components/url-parser/UrlParser.ts index 39df819..96b1fde 100644 --- a/src/tools/components/url-parser/UrlParser.ts +++ b/src/tools/components/url-parser/UrlParser.ts @@ -332,7 +332,6 @@ export class UrlParser extends BaseTool { } this.input = url.toString(); - this.parseUrl(); this.alert = null; } catch (error) { this.alert = { From 348e5a2f42844cc8392c136d9cb75691f68ff83a Mon Sep 17 00:00:00 2001 From: Taylon Chan Date: Sun, 29 Mar 2026 20:24:00 +0800 Subject: [PATCH 12/16] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20|=20Rearrange=20summ?= =?UTF-8?q?ary=20card=20layout=20for=20improved=20readability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crontab-generator/CrontabGenerator.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/tools/components/crontab-generator/CrontabGenerator.ts b/src/tools/components/crontab-generator/CrontabGenerator.ts index d161ffb..2a60636 100644 --- a/src/tools/components/crontab-generator/CrontabGenerator.ts +++ b/src/tools/components/crontab-generator/CrontabGenerator.ts @@ -197,19 +197,7 @@ export class CrontabGenerator extends BaseTool {

Crontab is a time-based job scheduler in Unix-like operating systems. It allows users to schedule tasks to run automatically at specified times and intervals.


-
-
- Next at - ${this.nextAt} -
-
- -
-
Human readable
-
${this.humanReadable}
-
- -
+