init commit
This commit is contained in:
commit
b7b1fd306a
32 changed files with 4527 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v25
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# CLAUDE.md — toolkit
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
SvelteKit 2 + Svelte 5 developer toolkit. All tools run client-side — no server-side logic.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**JavaScript only** (`lang="js"` in Svelte files). No TypeScript. Use JSDoc for type annotations.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="js">
|
||||||
|
/** @type {string} */
|
||||||
|
let value = $state('');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Svelte 5 runes
|
||||||
|
|
||||||
|
Use `$state`, `$derived`, `$derived.by`, `$effect`, `$props`. No Svelte 4 syntax (`$:`, `export let`, `<slot>`).
|
||||||
|
|
||||||
|
## Style conventions
|
||||||
|
|
||||||
|
- Arrow functions for single-expression bodies
|
||||||
|
- Regular functions for multi-line logic
|
||||||
|
- Imports at top of `<script>`
|
||||||
|
- No `function` declarations after state/derived blocks
|
||||||
|
|
||||||
|
## Shared libraries
|
||||||
|
|
||||||
|
- `$lib/clipboard.js` — `copy(text)` — wraps `navigator.clipboard.writeText`
|
||||||
|
- `$lib/md5.js` — `md5(input: string | Uint8Array): string` — no-dep MD5
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
File-based under `src/routes/`. Each tool is a directory with `+page.svelte`.
|
||||||
|
|
||||||
|
| Route | Tool |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `/` | About — project description + tools index |
|
||||||
|
| `/object-id` | MongoDB ObjectId generator & parser |
|
||||||
|
| `/uuid` | UUID v1/v3/v4/v5/v6/v7 generator & parser |
|
||||||
|
| `/hash` | MD5, SHA-1, SHA-256, SHA-512 |
|
||||||
|
| `/cron` | Crontab expression builder |
|
||||||
|
| `/api-key` | High-entropy API key generator |
|
||||||
|
| `/json` | JSON prettify / compact / validate |
|
||||||
|
| `/base` | Base64 / Base64url / Base32 / Base58 / Base16 encode & decode |
|
||||||
|
| `/string` | String transforms: lower, UPPER, snake_case, camelCase, PascalCase, kebab, reverse, trim |
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Adding a new tool requires updating three files:
|
||||||
|
1. `src/routes/+layout.svelte` — add entry to `links` array
|
||||||
|
2. `src/routes/+page.svelte` — add entry to `tools` array
|
||||||
|
3. `CLAUDE.md` — add route to the routing table
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
Colors are defined as CSS custom properties in `src/routes/layout.css`.
|
||||||
|
|
||||||
|
- Default theme: **Gruvbox Light Hard**
|
||||||
|
- Dark theme: **Gruvbox Dark Hard** — activated via `[data-theme="dark"]` on `<html>`
|
||||||
|
- Theme toggle button lives in `src/routes/Header.svelte`; persists to `localStorage`
|
||||||
|
- Theme is applied before first render via an inline `<script>` in `src/app.html` (no FOUC)
|
||||||
|
|
||||||
|
Key CSS variables: `--bg`, `--bg-panel`, `--bg-raised`, `--bg-raised-hover`, `--fg`, `--fg-dim`, `--fg-faint`, `--border`, `--border-faint`, `--active-bg`, `--active-fg`, `--link`, `--link-vis`, `--link-hover`, `--err`, `--err-bg`, `--err-border`, `--green`, `--blue`, `--red`, `--bar-bg`, `--bar-fg`, `--bar-dim`.
|
||||||
|
|
||||||
|
**Never hardcode colors.** Always use CSS variables.
|
||||||
|
|
||||||
|
## Panel UI pattern
|
||||||
|
|
||||||
|
Panels (textarea/output areas) use a consistent structure and class names:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Label</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button>clear</button>
|
||||||
|
<button>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea ...></textarea>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS for `.panel-header`: `background: var(--bg-raised)`, `border: 1px solid var(--border)`, `border-bottom: none`, `padding: 2px 6px`, `font-size: 0.85em`, `font-weight: bold`.
|
||||||
|
|
||||||
|
## Active/selected state
|
||||||
|
|
||||||
|
Buttons and nav tiles use: `background: var(--active-bg); color: var(--active-fg); border-color: var(--active-bg)`.
|
||||||
|
|
||||||
|
## CSS
|
||||||
|
|
||||||
|
- Global styles: `src/routes/layout.css` — variables, body, a, button, input, select, textarea, table, h1, h2, hr, code
|
||||||
|
- Component styles: scoped `<style>` blocks inside each `.svelte` file
|
||||||
|
- Aesthetic: suckless/early-2000s — Arial, 1px solid borders, no gradients/shadows/border-radius
|
||||||
|
|
||||||
|
## Crypto
|
||||||
|
|
||||||
|
All randomness via `crypto.getRandomValues()`. Hash via `crypto.subtle.digest()`. No external crypto libs except the custom MD5 (needed for UUID v3 and hash page).
|
||||||
|
|
||||||
|
## No external runtime dependencies
|
||||||
|
|
||||||
|
`package.json` has only build-time devDependencies. Keep it that way.
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Stage 1: build
|
||||||
|
FROM node:25-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: runtime
|
||||||
|
FROM node:25-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
CMD ["node", "build"]
|
||||||
86
README.md
Normal file
86
README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# toolkit
|
||||||
|
|
||||||
|
A collection of browser-based tools for developers.
|
||||||
|
No server, no tracking, no external dependencies — everything runs locally in your browser.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|--------------|------------------------------------------------------------------|
|
||||||
|
| `/` | About |
|
||||||
|
| `/object-id` | MongoDB ObjectId generator & parser |
|
||||||
|
| `/uuid` | UUID v1/v4/v6/v7 generator & parser |
|
||||||
|
| `/hash` | MD5, SHA-1, SHA-256, SHA-512 |
|
||||||
|
| `/cron` | Interactive crontab expression builder with presets |
|
||||||
|
| `/api-key` | High-entropy API key generator (hex, base62, base64url, …) |
|
||||||
|
| `/json` | Prettify, compact, tab-indent, validate JSON |
|
||||||
|
| `/base` | Encode & decode Base64, Base64url, Base32, Base58, Base16 |
|
||||||
|
| `/string` | String transforms: lower, UPPER, snake_case, camelCase, … |
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- [SvelteKit 2](https://kit.svelte.dev) + [Svelte 5](https://svelte.dev)
|
||||||
|
- JavaScript with JSDoc — no TypeScript
|
||||||
|
- No runtime dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
App runs at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
node build # listens on PORT (default 3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy with Docker
|
||||||
|
|
||||||
|
### Build and run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t toolkit .
|
||||||
|
docker run -p 3000:3000 -e ORIGIN=http://localhost:3000 toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
### docker-compose
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
App runs at `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Behind a reverse proxy
|
||||||
|
|
||||||
|
Set `ORIGIN` to your public URL in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- ORIGIN=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Then proxy traffic to port `3000`.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|------------|---------------------------------------|
|
||||||
|
| `PORT` | `3000` | Port the server listens on |
|
||||||
|
| `HOST` | `0.0.0.0` | Interface to bind |
|
||||||
|
| `ORIGIN` | — | Public URL (required in production) |
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
services:
|
||||||
|
toolkit:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- ORIGIN=http://localhost:3000
|
||||||
|
restart: unless-stopped
|
||||||
19
jsconfig.json
Normal file
19
jsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
1854
package-lock.json
generated
Normal file
1854
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "toolkit",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"svelte": "^5.51.0",
|
||||||
|
"svelte-check": "^4.4.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
16
src/app.html
Normal file
16
src/app.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
const t = localStorage.getItem('theme');
|
||||||
|
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
</script>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
src/lib/clipboard.js
Normal file
6
src/lib/clipboard.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const copy = (text) => void navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
1
src/lib/images/svelte-logo.svg
Normal file
1
src/lib/images/svelte-logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" style="fill:#ff3e00"/><path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
66
src/lib/md5.js
Normal file
66
src/lib/md5.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Compact MD5 (no dependencies). Accepts a UTF-8 string or raw bytes.
|
||||||
|
* @param {string | Uint8Array} input
|
||||||
|
* @returns {string} hex digest
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {number} x @param {number} y @returns {number} */
|
||||||
|
function add(x, y) {
|
||||||
|
const lsw = (x & 0xffff) + (y & 0xffff);
|
||||||
|
return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} n @param {number} s @returns {number} */
|
||||||
|
const rol = (n, s) => (n << s) | (n >>> (32 - s));
|
||||||
|
|
||||||
|
// Per-round shift amounts (4 rounds × 4 positions)
|
||||||
|
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
|
||||||
|
|
||||||
|
// Round constants: floor(abs(sin(i+1)) * 2^32)
|
||||||
|
const K = new Int32Array([
|
||||||
|
-680876936, -389564586, 606105819, -1044525330, -176418897, 1200080426, -1473231341, -45705983,
|
||||||
|
1770035416,-1958414417, -42063, -1990404162, 1804603682, -40341101, -1502002290, 1236535329,
|
||||||
|
-165796510,-1069501632, 643717713, -373897302, -701558691, 38016083, -660478335, -405537848,
|
||||||
|
568446438,-1019803690, -187363961, 1163531501,-1444681467, -51403784, 1735328473, -1926607734,
|
||||||
|
-378558,-2022574463, 1839030562, -35309556,-1530992060, 1272893353, -155497632, -1094730640,
|
||||||
|
681279174, -358537222, -722521979, 76029189, -640364487, -421815835, 530742520, -995338651,
|
||||||
|
-198630844, 1126891415,-1416354905, -57434055, 1700485571,-1894986606, -1051523, -2054922799,
|
||||||
|
1873313359, -30611744,-1560198380, 1309151649, -145523070,-1120210379, 718787259, -343485551,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @param {string | Uint8Array} input @returns {string} */
|
||||||
|
export function md5(input) {
|
||||||
|
const bytes = input instanceof Uint8Array ? input : new TextEncoder().encode(input);
|
||||||
|
const len = bytes.length;
|
||||||
|
|
||||||
|
// Pad to 512-bit blocks: append 0x80, zeros, then 64-bit LE bit-length
|
||||||
|
const padded = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||||
|
padded.set(bytes);
|
||||||
|
padded[len] = 0x80;
|
||||||
|
new DataView(padded.buffer).setUint32(padded.length - 8, len * 8, true);
|
||||||
|
|
||||||
|
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
|
||||||
|
|
||||||
|
for (let off = 0; off < padded.length; off += 64) {
|
||||||
|
const M = new Int32Array(padded.buffer, off, 16);
|
||||||
|
const oa = a, ob = b, oc = c, od = d;
|
||||||
|
|
||||||
|
for (let j = 0; j < 64; j++) {
|
||||||
|
let f, g;
|
||||||
|
if (j < 16) { f = (b & c) | (~b & d); g = j; }
|
||||||
|
else if (j < 32) { f = (b & d) | (c & ~d); g = (5 * j + 1) & 15; }
|
||||||
|
else if (j < 48) { f = b ^ c ^ d; g = (3 * j + 5) & 15; }
|
||||||
|
else { f = c ^ (b | ~d); g = (7 * j) & 15; }
|
||||||
|
|
||||||
|
const tmp = add(add(a, f), add(M[g], K[j]));
|
||||||
|
a = d; d = c; c = b;
|
||||||
|
b = add(b, rol(tmp, S[j >> 4 << 2 | j & 3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
a = add(a, oa); b = add(b, ob); c = add(c, oc); d = add(d, od);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [a, b, c, d]
|
||||||
|
.map(n => Array.from({ length: 4 }, (_, j) => ((n >>> (j * 8)) & 0xff).toString(16).padStart(2, '0')).join(''))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
134
src/routes/+layout.svelte
Normal file
134
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script>
|
||||||
|
import './layout.css';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
/** @type {{children: import('svelte').Snippet}} */
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/object-id', label: 'object id' },
|
||||||
|
{ href: '/uuid', label: 'uuid' },
|
||||||
|
{ href: '/hash', label: 'hash' },
|
||||||
|
{ href: '/cron', label: 'cron' },
|
||||||
|
{ href: '/api-key', label: 'api key' },
|
||||||
|
{ href: '/json', label: 'json' },
|
||||||
|
{ href: '/base', label: 'base' },
|
||||||
|
{ href: '/string', label: 'string' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let theme = $state('light');
|
||||||
|
$effect(() => { theme = localStorage.getItem('theme') || 'light'; });
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
theme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : '');
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<nav>
|
||||||
|
<div class="nav-header">
|
||||||
|
<a class="site-title" href={resolve('/')}>Toolkit</a>
|
||||||
|
<button class="theme-btn" onclick={toggleTheme}>{theme === 'light' ? 'dark' : 'light'}</button>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each links as link}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={resolve(link.href)}
|
||||||
|
class:active={page.url.pathname === link.href || (link.href !== '/' && page.url.pathname.startsWith(link.href))}
|
||||||
|
>{link.label}</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
width: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-header {
|
||||||
|
display: flex;
|
||||||
|
font-size: 20px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title:visited {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title:hover {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
li a {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--link);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:visited {
|
||||||
|
color: var(--link-vis);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a:hover {
|
||||||
|
background: var(--bg-raised-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
li a.active {
|
||||||
|
background: var(--active-bg);
|
||||||
|
color: var(--active-fg);
|
||||||
|
border-color: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/routes/+page.js
Normal file
3
src/routes/+page.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// since there's no dynamic data here, we can prerender
|
||||||
|
// it so that it gets served as a static asset in production
|
||||||
|
export const prerender = true;
|
||||||
71
src/routes/+page.svelte
Normal file
71
src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>Toolkit</title>
|
||||||
|
<meta name="description" content="Collection of useful browser-based tools for developers" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{ href: '/object-id', label: 'object id', desc: 'Generate & parse MongoDB ObjectIds (timestamp, random, counter)' },
|
||||||
|
{ href: '/uuid', label: 'uuid', desc: 'Generate UUIDs v1/v4/v6/v7, parse & inspect' },
|
||||||
|
{ href: '/hash', label: 'hash', desc: 'MD5, SHA-1, SHA-256, SHA-512' },
|
||||||
|
{ href: '/cron', label: 'cron', desc: 'Interactive crontab expression builder with presets' },
|
||||||
|
{ href: '/api-key', label: 'api key', desc: 'High-entropy API key generator (hex, base62, base64url, base58, custom)' },
|
||||||
|
{ href: '/json', label: 'json', desc: 'Prettify, compact, tab-indent, validate JSON' },
|
||||||
|
{ href: '/base', label: 'base', desc: 'Encode & decode Base64, Base64url, Base32, Base58, Base16' },
|
||||||
|
{ href: '/string', label: 'string', desc: 'String transforms: lower, UPPER, snake_case, camelCase, PascalCase, kebab-case, reverse, trim' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>About</h1>
|
||||||
|
|
||||||
|
<p class="about">
|
||||||
|
A collection of browser-based tools for developers.<br>
|
||||||
|
No server, no tracking, no external dependencies - everything runs locally in your browser.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Tools</h2>
|
||||||
|
|
||||||
|
<table class="tools">
|
||||||
|
<tbody>
|
||||||
|
{#each tools as tool}
|
||||||
|
<tr>
|
||||||
|
<td class="name"><a href={resolve(tool.href)}>{tool.label}</a></td>
|
||||||
|
<td class="desc">{tool.desc}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Stack</h2>
|
||||||
|
<p class="about">
|
||||||
|
<a href="https://kit.svelte.dev">SvelteKit 2</a> +
|
||||||
|
<a href="https://svelte.dev">Svelte 5</a> ·
|
||||||
|
JavaScript with JSDoc ·
|
||||||
|
No runtime dependencies
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
286
src/routes/api-key/+page.svelte
Normal file
286
src/routes/api-key/+page.svelte
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>API Key Generator</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
const ALPHABETS = {
|
||||||
|
hex: '0123456789abcdef',
|
||||||
|
base62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
|
||||||
|
base58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',
|
||||||
|
alphanum: '0123456789abcdefghijklmnopqrstuvwxyz',
|
||||||
|
alpha: 'abcdefghijklmnopqrstuvwxyz',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cryptographically secure random string from alphabet.
|
||||||
|
* Uses rejection sampling to avoid modulo bias.
|
||||||
|
* @param {string} alphabet
|
||||||
|
* @param {number} length
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function randomString(alphabet, length) {
|
||||||
|
const chars = [];
|
||||||
|
// Find largest multiple of alphabet.length that fits in a byte (rejection sampling)
|
||||||
|
const max = 256 - (256 % alphabet.length);
|
||||||
|
while (chars.length < length) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
|
||||||
|
for (const b of bytes) {
|
||||||
|
if (b < max) {
|
||||||
|
chars.push(alphabet[b % alphabet.length]);
|
||||||
|
if (chars.length === length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate raw bytes as base64url (no padding).
|
||||||
|
* @param {number} byteCount
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function randomBase64url(byteCount) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(byteCount));
|
||||||
|
const base64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} bits
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function randomHex(bits) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(bits / 8));
|
||||||
|
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: string, label: string, desc: string }} Format
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Format[]} */
|
||||||
|
const formats = [
|
||||||
|
{ id: 'hex128', label: 'hex-128', desc: '128-bit hex (32 chars)' },
|
||||||
|
{ id: 'hex256', label: 'hex-256', desc: '256-bit hex (64 chars)' },
|
||||||
|
{ id: 'base64url', label: 'base64url', desc: '256-bit base64url (43 chars)' },
|
||||||
|
{ id: 'base62_32', label: 'base62-32', desc: '32 chars base62 (~190 bits)' },
|
||||||
|
{ id: 'base62_43', label: 'base62-43', desc: '43 chars base62 (~256 bits)' },
|
||||||
|
{ id: 'base58_32', label: 'base58-32', desc: '32 chars base58 (~187 bits)' },
|
||||||
|
{ id: 'alphanum_32',label: 'alnum-32', desc: '32 lowercase alphanumeric (~165 bits)' },
|
||||||
|
{ id: 'custom', label: 'custom', desc: 'custom alphabet & length' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param {string} id @param {string} alphabet @param {number} customLen @returns {string} */
|
||||||
|
function generate(id, alphabet, customLen) {
|
||||||
|
switch (id) {
|
||||||
|
case 'hex128': return randomHex(128);
|
||||||
|
case 'hex256': return randomHex(256);
|
||||||
|
case 'base64url': return randomBase64url(32);
|
||||||
|
case 'base62_32': return randomString(ALPHABETS.base62, 32);
|
||||||
|
case 'base62_43': return randomString(ALPHABETS.base62, 43);
|
||||||
|
case 'base58_32': return randomString(ALPHABETS.base58, 32);
|
||||||
|
case 'alphanum_32': return randomString(ALPHABETS.alphanum, 32);
|
||||||
|
case 'custom': return randomString(alphabet || ALPHABETS.base62, customLen);
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} alphabet @param {number} length @returns {number} */
|
||||||
|
const entropy = (alphabet, length) => Math.floor(Math.log2(alphabet.length) * length);
|
||||||
|
|
||||||
|
/** @param {string} id @param {string} customAlphabet @param {number} customLen @returns {number} */
|
||||||
|
function entropyFor(id, customAlphabet, customLen) {
|
||||||
|
switch (id) {
|
||||||
|
case 'hex128': return 128;
|
||||||
|
case 'hex256': return 256;
|
||||||
|
case 'base64url': return 256;
|
||||||
|
case 'base62_32': return entropy(ALPHABETS.base62, 32);
|
||||||
|
case 'base62_43': return entropy(ALPHABETS.base62, 43);
|
||||||
|
case 'base58_32': return entropy(ALPHABETS.base58, 32);
|
||||||
|
case 'alphanum_32': return entropy(ALPHABETS.alphanum, 32);
|
||||||
|
case 'custom': return entropy(customAlphabet || ALPHABETS.base62, customLen);
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state ---
|
||||||
|
|
||||||
|
let selectedFormat = $state('hex256');
|
||||||
|
let prefix = $state('');
|
||||||
|
let customAlphabet = $state(ALPHABETS.base62);
|
||||||
|
let customLength = $state(32);
|
||||||
|
let batchCount = $state(5);
|
||||||
|
|
||||||
|
/** @type {{ key: string }[]} */
|
||||||
|
let keys = $state([]);
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
keys = Array.from({ length: batchCount }, () => ({
|
||||||
|
key: (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate on load
|
||||||
|
regenerate();
|
||||||
|
|
||||||
|
const currentEntropy = $derived(entropyFor(selectedFormat, customAlphabet, customLength));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>API Key Generator</h1>
|
||||||
|
|
||||||
|
<h2>Format</h2>
|
||||||
|
<table class="formats">
|
||||||
|
<tbody>
|
||||||
|
{#each formats as f}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={selectedFormat} value={f.id} onchange={regenerate} />
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="desc">{f.desc}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{#if selectedFormat === 'custom'}
|
||||||
|
<div class="custom-opts">
|
||||||
|
<label>
|
||||||
|
Alphabet:
|
||||||
|
<input type="text" bind:value={customAlphabet} size="50" spellcheck="false" oninput={regenerate} />
|
||||||
|
<span class="dim">({customAlphabet.length} chars)</span>
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<label>
|
||||||
|
Length:
|
||||||
|
<input type="number" bind:value={customLength} min="8" max="256" size="5" oninput={regenerate} />
|
||||||
|
</label>
|
||||||
|
<br>
|
||||||
|
<div class="quick-alpha">
|
||||||
|
{#each Object.entries(ALPHABETS) as [name, abc]}
|
||||||
|
<button onclick={() => { customAlphabet = abc; regenerate(); }}>{name}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2>Options</h2>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Prefix (optional):
|
||||||
|
<input type="text" bind:value={prefix} size="16" placeholder="sk, pk, token…" spellcheck="false" oninput={regenerate} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Count:
|
||||||
|
<input type="number" bind:value={batchCount} min="1" max="50" size="4" oninput={regenerate} />
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Keys <span class="entropy">~{currentEntropy} bits of entropy</span></h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick={regenerate}>Regenerate all</button>
|
||||||
|
<button onclick={() => copy(keys.map(k => k.key).join('\n'))}>Copy all</button>
|
||||||
|
</div>
|
||||||
|
<table class="keys">
|
||||||
|
<tbody>
|
||||||
|
{#each keys as k, i}
|
||||||
|
<tr>
|
||||||
|
<td class="idx">{i + 1}</td>
|
||||||
|
<td><code>{k.key}</code></td>
|
||||||
|
<td>
|
||||||
|
<button onclick={() => {
|
||||||
|
k.key = (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength);
|
||||||
|
}}>new</button>
|
||||||
|
</td>
|
||||||
|
<td><button onclick={() => copy(k.key)}>copy</button></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.formats {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formats td {
|
||||||
|
padding: 2px 10px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-opts {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-opts label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-alpha {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--fg-faint);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entropy {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--green);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys code {
|
||||||
|
font-size: 0.95em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
color: var(--fg-faint);
|
||||||
|
font-size: 0.85em;
|
||||||
|
width: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys td:nth-child(3),
|
||||||
|
.keys td:nth-child(4) {
|
||||||
|
width: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
317
src/routes/base/+page.svelte
Normal file
317
src/routes/base/+page.svelte
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>Base Encoder / Decoder</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
// --- Base64 ---
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
const encodeBase64 = (s) => btoa(unescape(encodeURIComponent(s)));
|
||||||
|
|
||||||
|
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
|
||||||
|
function decodeBase64(s) {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: decodeURIComponent(escape(atob(s.trim()))) };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Invalid Base64' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
const encodeBase64url = (s) => encodeBase64(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
|
||||||
|
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
|
||||||
|
function decodeBase64url(s) {
|
||||||
|
const padded = s.trim().replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const pad = (4 - padded.length % 4) % 4;
|
||||||
|
return decodeBase64(padded + '='.repeat(pad));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Base32 (RFC 4648) ---
|
||||||
|
|
||||||
|
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
function encodeBase32(s) {
|
||||||
|
const bytes = new TextEncoder().encode(s);
|
||||||
|
let bits = 0, buf = 0, out = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
buf = (buf << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
bits -= 5;
|
||||||
|
out += B32_ALPHABET[(buf >> bits) & 0x1f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) out += B32_ALPHABET[(buf << (5 - bits)) & 0x1f];
|
||||||
|
while (out.length % 8 !== 0) out += '=';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
|
||||||
|
function decodeBase32(s) {
|
||||||
|
const clean = s.trim().toUpperCase().replace(/=+$/, '');
|
||||||
|
let bits = 0, buf = 0;
|
||||||
|
const bytes = [];
|
||||||
|
for (const ch of clean) {
|
||||||
|
const idx = B32_ALPHABET.indexOf(ch);
|
||||||
|
if (idx === -1) return { ok: false, error: `Invalid Base32 character: '${ch}'` };
|
||||||
|
buf = (buf << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
bytes.push((buf >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { ok: true, value: new TextDecoder().decode(new Uint8Array(bytes)) };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Could not decode as UTF-8' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Base58 (Bitcoin alphabet) ---
|
||||||
|
|
||||||
|
const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
function encodeBase58(s) {
|
||||||
|
const bytes = new TextEncoder().encode(s);
|
||||||
|
let num = 0n;
|
||||||
|
for (const b of bytes) num = (num << 8n) | BigInt(b);
|
||||||
|
let out = '';
|
||||||
|
while (num > 0n) {
|
||||||
|
out = B58_ALPHABET[Number(num % 58n)] + out;
|
||||||
|
num /= 58n;
|
||||||
|
}
|
||||||
|
// leading zero bytes → leading '1's
|
||||||
|
for (const b of bytes) {
|
||||||
|
if (b !== 0) break;
|
||||||
|
out = '1' + out;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
|
||||||
|
function decodeBase58(s) {
|
||||||
|
const clean = s.trim();
|
||||||
|
let num = 0n;
|
||||||
|
for (const ch of clean) {
|
||||||
|
const idx = B58_ALPHABET.indexOf(ch);
|
||||||
|
if (idx === -1) return { ok: false, error: `Invalid Base58 character: '${ch}'` };
|
||||||
|
num = num * 58n + BigInt(idx);
|
||||||
|
}
|
||||||
|
const bytes = [];
|
||||||
|
while (num > 0n) {
|
||||||
|
bytes.unshift(Number(num & 0xffn));
|
||||||
|
num >>= 8n;
|
||||||
|
}
|
||||||
|
for (const ch of clean) {
|
||||||
|
if (ch !== '1') break;
|
||||||
|
bytes.unshift(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { ok: true, value: new TextDecoder().decode(new Uint8Array(bytes)) };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Could not decode as UTF-8' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Base16 (hex) ---
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
const encodeBase16 = (s) => Array.from(new TextEncoder().encode(s), b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
|
||||||
|
function decodeBase16(s) {
|
||||||
|
const clean = s.trim().replace(/\s/g, '');
|
||||||
|
if (!/^[0-9a-fA-F]*$/.test(clean)) return { ok: false, error: 'Invalid hex character' };
|
||||||
|
if (clean.length % 2 !== 0) return { ok: false, error: 'Odd number of hex characters' };
|
||||||
|
const bytes = new Uint8Array(clean.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
try {
|
||||||
|
return { ok: true, value: new TextDecoder().decode(bytes) };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Could not decode as UTF-8' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state ---
|
||||||
|
|
||||||
|
/** @type {'base64' | 'base64url' | 'base32' | 'base58' | 'base16'} */
|
||||||
|
let encoding = $state('base64');
|
||||||
|
|
||||||
|
/** @type {'encode' | 'decode'} */
|
||||||
|
let tab = $state('encode');
|
||||||
|
|
||||||
|
let plainInput = $state('');
|
||||||
|
let encodedInput = $state('');
|
||||||
|
|
||||||
|
const encodeResult = $derived.by(() => {
|
||||||
|
if (!plainInput) return { ok: true, value: '' };
|
||||||
|
if (encoding === 'base64') return { ok: true, value: encodeBase64(plainInput) };
|
||||||
|
if (encoding === 'base64url') return { ok: true, value: encodeBase64url(plainInput) };
|
||||||
|
if (encoding === 'base58') return { ok: true, value: encodeBase58(plainInput) };
|
||||||
|
if (encoding === 'base16') return { ok: true, value: encodeBase16(plainInput) };
|
||||||
|
return { ok: true, value: encodeBase32(plainInput) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const decodeResult = $derived.by(() => {
|
||||||
|
if (!encodedInput) return { ok: true, value: '' };
|
||||||
|
if (encoding === 'base64') return decodeBase64(encodedInput);
|
||||||
|
if (encoding === 'base64url') return decodeBase64url(encodedInput);
|
||||||
|
if (encoding === 'base58') return decodeBase58(encodedInput);
|
||||||
|
if (encoding === 'base16') return decodeBase16(encodedInput);
|
||||||
|
return decodeBase32(encodedInput);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Base Encoding</h1>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="enc-switch">
|
||||||
|
{#each ['base64', 'base64url', 'base32', 'base58', 'base16'] as enc}
|
||||||
|
<button
|
||||||
|
class:active={encoding === enc}
|
||||||
|
onclick={() => { encoding = enc; }}
|
||||||
|
>{enc}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="tab-switch">
|
||||||
|
<button class:active={tab === 'encode'} onclick={() => { tab = 'encode'; }}>encode</button>
|
||||||
|
<button class:active={tab === 'decode'} onclick={() => { tab = 'decode'; }}>decode</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'encode'}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Plain text</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => { plainInput = ''; }}>clear</button>
|
||||||
|
<button disabled={!encodeResult.ok || !encodeResult.value} onclick={() => copy(/** @type {any} */ (encodeResult).value)}>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:value={plainInput}
|
||||||
|
placeholder="Type or paste text to encode…"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
{#if plainInput}
|
||||||
|
{#if encodeResult.ok && encodeResult.value}
|
||||||
|
<div class="result-row">
|
||||||
|
<code class="result">{encodeResult.value}</code>
|
||||||
|
</div>
|
||||||
|
{:else if !encodeResult.ok}
|
||||||
|
<div class="error">{encodeResult.error}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Encoded</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => { encodedInput = ''; }}>clear</button>
|
||||||
|
<button disabled={!decodeResult.ok || !decodeResult.value} onclick={() => copy(/** @type {any} */ (decodeResult).value)}>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:value={encodedInput}
|
||||||
|
placeholder="Paste encoded string to decode…"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
{#if encodedInput}
|
||||||
|
{#if decodeResult.ok && decodeResult.value}
|
||||||
|
<div class="result-row">
|
||||||
|
<code class="result">{decodeResult.value}</code>
|
||||||
|
</div>
|
||||||
|
{:else if !decodeResult.ok}
|
||||||
|
<div class="error">{decodeResult.error}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enc-switch,
|
||||||
|
.tab-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enc-switch button.active,
|
||||||
|
.tab-switch button.active {
|
||||||
|
background: var(--active-bg);
|
||||||
|
color: var(--active-fg);
|
||||||
|
border-color: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 40vh;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 4px 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
font-size: 0.85em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
border: 1px solid var(--err-border);
|
||||||
|
border-top: none;
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err);
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
370
src/routes/cron/+page.svelte
Normal file
370
src/routes/cron/+page.svelte
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>Crontab Generator</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ label: string, value: string }[]} Presets
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {{ label: string, name: string, placeholder: string, presets: Presets }[]} */
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
label: 'Minute',
|
||||||
|
name: 'minute',
|
||||||
|
placeholder: '0-59',
|
||||||
|
presets: [
|
||||||
|
{ label: '*', value: '*' },
|
||||||
|
{ label: '*/5', value: '*/5' },
|
||||||
|
{ label: '*/10', value: '*/10' },
|
||||||
|
{ label: '*/15', value: '*/15' },
|
||||||
|
{ label: '*/30', value: '*/30' },
|
||||||
|
{ label: '0', value: '0' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hour',
|
||||||
|
name: 'hour',
|
||||||
|
placeholder: '0-23',
|
||||||
|
presets: [
|
||||||
|
{ label: '*', value: '*' },
|
||||||
|
{ label: '*/2', value: '*/2' },
|
||||||
|
{ label: '*/4', value: '*/4' },
|
||||||
|
{ label: '*/6', value: '*/6' },
|
||||||
|
{ label: '*/12', value: '*/12' },
|
||||||
|
{ label: '0', value: '0' },
|
||||||
|
{ label: '6', value: '6' },
|
||||||
|
{ label: '12', value: '12' },
|
||||||
|
{ label: '18', value: '18' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Day (month)',
|
||||||
|
name: 'dom',
|
||||||
|
placeholder: '1-31',
|
||||||
|
presets: [
|
||||||
|
{ label: '*', value: '*' },
|
||||||
|
{ label: '1', value: '1' },
|
||||||
|
{ label: '15', value: '15' },
|
||||||
|
{ label: 'L', value: 'L' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Month',
|
||||||
|
name: 'month',
|
||||||
|
placeholder: '1-12',
|
||||||
|
presets: [
|
||||||
|
{ label: '*', value: '*' },
|
||||||
|
{ label: '1', value: '1' },
|
||||||
|
{ label: '3', value: '3' },
|
||||||
|
{ label: '6', value: '6' },
|
||||||
|
{ label: '9', value: '9' },
|
||||||
|
{ label: '12', value: '12' },
|
||||||
|
{ label: '1-6', value: '1-6' },
|
||||||
|
{ label: '7-12', value: '7-12' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Day (week)',
|
||||||
|
name: 'dow',
|
||||||
|
placeholder: '0-7',
|
||||||
|
presets: [
|
||||||
|
{ label: '*', value: '*' },
|
||||||
|
{ label: 'Mon–Fri', value: '1-5' },
|
||||||
|
{ label: 'Sat–Sun', value: '6,0' },
|
||||||
|
{ label: 'Mon', value: '1' },
|
||||||
|
{ label: 'Wed', value: '3' },
|
||||||
|
{ label: 'Fri', value: '5' },
|
||||||
|
{ label: 'Sat', value: '6' },
|
||||||
|
{ label: 'Sun', value: '0' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
let values = $state({
|
||||||
|
minute: '*',
|
||||||
|
hour: '*',
|
||||||
|
dom: '*',
|
||||||
|
month: '*',
|
||||||
|
dow: '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
const expression = $derived(
|
||||||
|
`${values.minute} ${values.hour} ${values.dom} ${values.month} ${values.dow}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @param {string} field @param {string} val */
|
||||||
|
const set = (field, val) => { values[field] = val; };
|
||||||
|
|
||||||
|
const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MON_NAMES = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
/** @param {string} v @returns {string} */
|
||||||
|
function describeMinute(v) {
|
||||||
|
if (v === '*') return 'every minute';
|
||||||
|
if (v === '0') return 'at minute 0';
|
||||||
|
if (v.startsWith('*/')) return `every ${v.slice(2)} minutes`;
|
||||||
|
return `at minute ${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} v @returns {string} */
|
||||||
|
function describeHour(v) {
|
||||||
|
if (v === '*') return 'every hour';
|
||||||
|
if (v.startsWith('*/')) return `every ${v.slice(2)} hours`;
|
||||||
|
if (/^\d+$/.test(v)) return `at ${v.padStart(2, '0')}:xx`;
|
||||||
|
return `at hour ${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} v @returns {string} */
|
||||||
|
function describeDom(v) {
|
||||||
|
if (v === '*') return '';
|
||||||
|
if (v === 'L') return 'on the last day of the month';
|
||||||
|
if (/^\d+$/.test(v)) return `on day ${v} of the month`;
|
||||||
|
return `on day-of-month ${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} v @returns {string} */
|
||||||
|
function describeMonth(v) {
|
||||||
|
if (v === '*') return '';
|
||||||
|
if (/^\d+$/.test(v)) return `in ${MON_NAMES[+v] ?? v}`;
|
||||||
|
if (v.includes('-')) {
|
||||||
|
const [a, b] = v.split('-');
|
||||||
|
return `from ${MON_NAMES[+a] ?? a} to ${MON_NAMES[+b] ?? b}`;
|
||||||
|
}
|
||||||
|
return `in month ${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} v @returns {string} */
|
||||||
|
function describeDow(v) {
|
||||||
|
if (v === '*') return '';
|
||||||
|
if (v === '1-5') return 'on weekdays (Mon–Fri)';
|
||||||
|
if (v === '6,0' || v === '0,6') return 'on weekends';
|
||||||
|
if (/^\d+$/.test(v)) return `on ${DOW_NAMES[+v] ?? v}`;
|
||||||
|
if (v.includes('-')) {
|
||||||
|
const [a, b] = v.split('-');
|
||||||
|
return `from ${DOW_NAMES[+a] ?? a} to ${DOW_NAMES[+b] ?? b}`;
|
||||||
|
}
|
||||||
|
return `on day-of-week ${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = $derived((() => {
|
||||||
|
const min = describeMinute(values.minute);
|
||||||
|
const hr = describeHour(values.hour);
|
||||||
|
const dom = describeDom(values.dom);
|
||||||
|
const mon = describeMonth(values.month);
|
||||||
|
const dow = describeDow(values.dow);
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
|
||||||
|
if (values.minute === '*' && values.hour === '*') {
|
||||||
|
parts.push('every minute');
|
||||||
|
} else if (values.minute === '0' && values.hour !== '*') {
|
||||||
|
parts.push(`at ${values.hour.padStart(2,'0')}:00`);
|
||||||
|
} else if (values.hour === '*') {
|
||||||
|
parts.push(min);
|
||||||
|
} else {
|
||||||
|
parts.push(`${min}, ${hr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dom) parts.push(dom);
|
||||||
|
if (mon) parts.push(mon);
|
||||||
|
if (dow) parts.push(dow);
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
})());
|
||||||
|
|
||||||
|
/** Common presets */
|
||||||
|
const quickPresets = [
|
||||||
|
{ label: 'Every minute', expr: '* * * * *' },
|
||||||
|
{ label: 'Every 5 minutes', expr: '*/5 * * * *' },
|
||||||
|
{ label: 'Every 15 minutes', expr: '*/15 * * * *' },
|
||||||
|
{ label: 'Every 30 minutes', expr: '*/30 * * * *' },
|
||||||
|
{ label: 'Every hour', expr: '0 * * * *' },
|
||||||
|
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
|
||||||
|
{ label: 'Every day at midnight', expr: '0 0 * * *' },
|
||||||
|
{ label: 'Every day at noon', expr: '0 12 * * *' },
|
||||||
|
{ label: 'Every Mon at 09:00',expr: '0 9 * * 1' },
|
||||||
|
{ label: 'Weekdays at 08:00', expr: '0 8 * * 1-5' },
|
||||||
|
{ label: 'Every Sunday at 03:00', expr: '0 3 * * 0' },
|
||||||
|
{ label: '1st of each month', expr: '0 0 1 * *' },
|
||||||
|
{ label: 'Every quarter', expr: '0 0 1 */3 *' },
|
||||||
|
{ label: 'Once a year (Jan 1)',expr: '0 0 1 1 *' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param {string} expr */
|
||||||
|
function applyPreset(expr) {
|
||||||
|
const parts = expr.split(' ');
|
||||||
|
values.minute = parts[0];
|
||||||
|
values.hour = parts[1];
|
||||||
|
values.dom = parts[2];
|
||||||
|
values.month = parts[3];
|
||||||
|
values.dow = parts[4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} expr */
|
||||||
|
function parseExpression(expr) {
|
||||||
|
const parts = expr.trim().split(/\s+/);
|
||||||
|
if (parts.length === 5) applyPreset(parts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
let manualInput = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
manualInput = expression;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Cron</h1>
|
||||||
|
|
||||||
|
<h2>Quick presets</h2>
|
||||||
|
<div class="presets">
|
||||||
|
{#each quickPresets as p}
|
||||||
|
<button onclick={() => applyPreset(p.expr)}>{p.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Builder</h2>
|
||||||
|
<table class="builder">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each fields as f}
|
||||||
|
<th>{f.label}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
{#each fields as f}
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={values[f.name]}
|
||||||
|
oninput={(e) => set(f.name, /** @type {HTMLInputElement} */ (e.target).value)}
|
||||||
|
size="6"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{#each fields as f}
|
||||||
|
<td class="chips">
|
||||||
|
{#each f.presets as p}
|
||||||
|
<button
|
||||||
|
class:selected={values[f.name] === p.value}
|
||||||
|
onclick={() => set(f.name, p.value)}
|
||||||
|
>{p.label}</button>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Expression</h2>
|
||||||
|
<p>
|
||||||
|
<code class="expr">{expression}</code>
|
||||||
|
<button onclick={() => copy(expression)}>copy</button>
|
||||||
|
</p>
|
||||||
|
<p class="desc">{description}</p>
|
||||||
|
|
||||||
|
<h2>Paste & parse</h2>
|
||||||
|
<p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={manualInput}
|
||||||
|
size="30"
|
||||||
|
placeholder="* * * * *"
|
||||||
|
spellcheck="false"
|
||||||
|
oninput={() => parseExpression(manualInput)}
|
||||||
|
/>
|
||||||
|
<button onclick={() => parseExpression(manualInput)}>parse</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Reference</h2>
|
||||||
|
<table class="ref">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Field</th><th>Range</th><th>Special</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Minute</td><td>0–59</td><td rowspan="5"><code>*</code> any <code>,</code> list <code>-</code> range <code>/</code> step</td></tr>
|
||||||
|
<tr><td>Hour</td><td>0–23</td></tr>
|
||||||
|
<tr><td>Day (month)</td><td>1–31</td></tr>
|
||||||
|
<tr><td>Month</td><td>1–12</td></tr>
|
||||||
|
<tr><td>Day (week)</td><td>0–7 (0,7=Sun)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder th,
|
||||||
|
.builder td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder th {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder input {
|
||||||
|
width: 60px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips button.selected {
|
||||||
|
background: var(--active-bg);
|
||||||
|
color: var(--active-fg);
|
||||||
|
border-color: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expr {
|
||||||
|
font-size: 1.3em;
|
||||||
|
margin-right: 8px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref th,
|
||||||
|
.ref td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref th {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
136
src/routes/hash/+page.svelte
Normal file
136
src/routes/hash/+page.svelte
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>Hash Generator</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { md5 } from '$lib/md5.js';
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
/** @param {AlgorithmIdentifier} algorithm @param {string} input @returns {Promise<string>} */
|
||||||
|
const sha = async (algorithm, input) => {
|
||||||
|
const buf = await crypto.subtle.digest(algorithm, new TextEncoder().encode(input));
|
||||||
|
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = $state('');
|
||||||
|
/** @type {HTMLTextAreaElement} */
|
||||||
|
let textareaEl = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
input;
|
||||||
|
if (textareaEl) {
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
textareaEl.style.height = 'auto';
|
||||||
|
textareaEl.style.height = textareaEl.scrollHeight + 'px';
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {{ label: string, value: string }[]} */
|
||||||
|
let hashes = $state([
|
||||||
|
{ label: 'MD5', value: '' },
|
||||||
|
{ label: 'SHA1', value: '' },
|
||||||
|
{ label: 'SHA256', value: '' },
|
||||||
|
{ label: 'SHA512', value: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const text = input;
|
||||||
|
hashes[0].value = md5(text);
|
||||||
|
sha('SHA-1', text).then(v => hashes[1].value = v);
|
||||||
|
sha('SHA-256', text).then(v => hashes[2].value = v);
|
||||||
|
sha('SHA-512', text).then(v => hashes[3].value = v);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Hash Generator</h1>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Plain text</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => { input = ''; }}>clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaEl}
|
||||||
|
bind:value={input}
|
||||||
|
placeholder="Enter text..."
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each hashes as hash}
|
||||||
|
<tr>
|
||||||
|
<td class="algo">{hash.label}</td>
|
||||||
|
<td class="value"><code>{hash.value}</code></td>
|
||||||
|
<td><button onclick={() => copy(hash.value)}>copy</button></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 60%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 2em;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
flex: 1;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.algo {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value code {
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
253
src/routes/json/+page.svelte
Normal file
253
src/routes/json/+page.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>JSON formatter</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
let input = $state('');
|
||||||
|
let indent = $state(2);
|
||||||
|
/** @type {'pretty' | 'compact' | 'tabs'} */
|
||||||
|
let mode = $state('pretty');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ ok: true, value: unknown } | { ok: false, error: string }} ParseResult
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {ParseResult} */
|
||||||
|
const parsed = $derived.by(() => {
|
||||||
|
const s = input.trim();
|
||||||
|
if (!s) return { ok: false, error: '' };
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(s) };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: /** @type {Error} */ (e).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pretty = $derived(
|
||||||
|
parsed.ok ? JSON.stringify(parsed.value, null, indent) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const compact = $derived(
|
||||||
|
parsed.ok ? JSON.stringify(parsed.value) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = $derived(
|
||||||
|
parsed.ok ? JSON.stringify(parsed.value, null, '\t') : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = $derived(
|
||||||
|
mode === 'compact' ? compact : mode === 'tabs' ? tabs : pretty
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @param {unknown} v @returns {string} */
|
||||||
|
function typeLabel(v) {
|
||||||
|
if (v === null) return 'null';
|
||||||
|
if (Array.isArray(v)) return `array[${v.length}]`;
|
||||||
|
if (typeof v === 'object') return `object{${Object.keys(/** @type {object} */ (v)).length}}`;
|
||||||
|
return typeof v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = $derived.by(() => {
|
||||||
|
if (!parsed.ok) return null;
|
||||||
|
const v = parsed.value;
|
||||||
|
return {
|
||||||
|
type: typeLabel(v),
|
||||||
|
size: new TextEncoder().encode(compact).length,
|
||||||
|
prettySize: new TextEncoder().encode(pretty).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const SAMPLE = `{
|
||||||
|
"name": "toolkit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"tags": ["dev", "tools"],
|
||||||
|
"meta": {
|
||||||
|
"author": "otter.su",
|
||||||
|
"active": true,
|
||||||
|
"count": 42,
|
||||||
|
"score": 3.14,
|
||||||
|
"nothing": null
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>JSON</h1>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="panel input-pane">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Input</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => { input = SAMPLE; }}>sample</button>
|
||||||
|
<button onclick={() => { input = ''; }}>clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:value={input}
|
||||||
|
placeholder="Paste JSON here…"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="middle">
|
||||||
|
<div class="ops">
|
||||||
|
<button
|
||||||
|
disabled={!parsed.ok}
|
||||||
|
class:active={mode === 'pretty'}
|
||||||
|
onclick={() => { mode = 'pretty'; }}
|
||||||
|
>pretty</button>
|
||||||
|
<button
|
||||||
|
disabled={!parsed.ok}
|
||||||
|
class:active={mode === 'compact'}
|
||||||
|
onclick={() => { mode = 'compact'; }}
|
||||||
|
>compact</button>
|
||||||
|
<button
|
||||||
|
disabled={!parsed.ok}
|
||||||
|
class:active={mode === 'tabs'}
|
||||||
|
onclick={() => { mode = 'tabs'; }}
|
||||||
|
>tabs</button>
|
||||||
|
{#if mode === 'pretty'}
|
||||||
|
<label>indent
|
||||||
|
<input type="number" bind:value={indent} min="1" max="8" size="2" />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
{#if input.trim() && !parsed.ok && parsed.error}
|
||||||
|
<span class="error">{parsed.error}</span>
|
||||||
|
{:else if parsed.ok && stats}
|
||||||
|
<span class="stats">
|
||||||
|
<span>type: <b>{stats.type}</b></span>
|
||||||
|
<span>compact: <b>{stats.size} B</b></span>
|
||||||
|
<span>pretty: <b>{stats.prettySize} B</b></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel output-pane">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Output</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button disabled={!parsed.ok} onclick={() => copy(output)}>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="output" class:error-bg={!parsed.ok && !!parsed.error}>{parsed.ok ? output : (parsed.error ? '— invalid JSON —' : '')}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-bg {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.middle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops button.active {
|
||||||
|
background: var(--active-bg);
|
||||||
|
color: var(--active-fg);
|
||||||
|
border-color: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops input {
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--err);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
src/routes/layout.css
Normal file
132
src/routes/layout.css
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
:root {
|
||||||
|
/* Gruvbox Light Hard */
|
||||||
|
--bg: #f9f5d7;
|
||||||
|
--bg-panel: #fbf1c7;
|
||||||
|
--bg-raised: #ebdbb2;
|
||||||
|
--bg-raised-hover: #d5c4a1;
|
||||||
|
--fg: #3c3836;
|
||||||
|
--fg-dim: #665c54;
|
||||||
|
--fg-faint: #928374;
|
||||||
|
--border: #a89984;
|
||||||
|
--border-faint: #bdae93;
|
||||||
|
--active-bg: #3c3836;
|
||||||
|
--active-fg: #f9f5d7;
|
||||||
|
--link: #458588;
|
||||||
|
--link-vis: #b16286;
|
||||||
|
--link-hover: #9d0006;
|
||||||
|
--err: #9d0006;
|
||||||
|
--err-bg: #f9e5e5;
|
||||||
|
--err-border: #cc241d;
|
||||||
|
--green: #98971a;
|
||||||
|
--blue: #458588;
|
||||||
|
--red: #cc241d;
|
||||||
|
--bar-bg: #3c3836;
|
||||||
|
--bar-fg: #f9f5d7;
|
||||||
|
--bar-dim: #928374;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
/* Gruvbox Dark Hard */
|
||||||
|
--bg: #1d2021;
|
||||||
|
--bg-panel: #282828;
|
||||||
|
--bg-raised: #3c3836;
|
||||||
|
--bg-raised-hover: #504945;
|
||||||
|
--fg: #ebdbb2;
|
||||||
|
--fg-dim: #bdae93;
|
||||||
|
--fg-faint: #928374;
|
||||||
|
--border: #665c54;
|
||||||
|
--border-faint: #504945;
|
||||||
|
--active-bg: #d5c4a1;
|
||||||
|
--active-fg: #1d2021;
|
||||||
|
--link: #83a598;
|
||||||
|
--link-vis: #d3869b;
|
||||||
|
--link-hover: #fb4934;
|
||||||
|
--err: #fb4934;
|
||||||
|
--err-bg: #3b2020;
|
||||||
|
--err-border: #cc241d;
|
||||||
|
--green: #b8bb26;
|
||||||
|
--blue: #83a598;
|
||||||
|
--red: #fb4934;
|
||||||
|
--bar-bg: #282828;
|
||||||
|
--bar-fg: #ebdbb2;
|
||||||
|
--bar-dim: #928374;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: var(--link-vis);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0.8em 0 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, textarea, select {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--bg-raised-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"] {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
130
src/routes/object-id/+page.svelte
Normal file
130
src/routes/object-id/+page.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>ObjectId Tools</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* timestamp:number,
|
||||||
|
* date:string,
|
||||||
|
* dateLocal:string,
|
||||||
|
* randomPart:string,
|
||||||
|
* counterPart:string}} ParsedObjectId
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ObjectId: 4 bytes timestamp | 5 bytes random | 3 bytes counter
|
||||||
|
function generateObjectId() {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const tsHex = timestamp.toString(16).padStart(8, '0');
|
||||||
|
|
||||||
|
const random = Array.from({ length: 10 }, () =>
|
||||||
|
Math.floor(Math.random() * 16).toString(16)
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const counter = Math.floor(Math.random() * 0xffffff);
|
||||||
|
const counterHex = counter.toString(16).padStart(6, '0');
|
||||||
|
|
||||||
|
return tsHex + random + counterHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {ParsedObjectId | { error: string }}
|
||||||
|
*/
|
||||||
|
function parseObjectId(id) {
|
||||||
|
if (!/^[0-9a-fA-F]{24}$/.test(id)) {
|
||||||
|
return { error: 'Invalid ObjectId: must be 24 hex characters' };
|
||||||
|
}
|
||||||
|
const tsHex = id.slice(0, 8);
|
||||||
|
const randomPart = id.slice(8, 18);
|
||||||
|
const counterPart = id.slice(18, 24);
|
||||||
|
const timestampSec = parseInt(tsHex, 16);
|
||||||
|
const date = new Date(timestampSec * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: timestampSec,
|
||||||
|
date: date.toISOString(),
|
||||||
|
dateLocal: date.toLocaleString(),
|
||||||
|
randomPart,
|
||||||
|
counterPart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let generated = $state(generateObjectId());
|
||||||
|
let parseInput = $state('');
|
||||||
|
|
||||||
|
const parseResult = $derived(parseInput.trim().length === 24 ? parseObjectId(parseInput.trim()) : null);
|
||||||
|
const parsed = $derived(parseResult && !('error' in parseResult) ? parseResult : null);
|
||||||
|
const parseError = $derived(parseResult && 'error' in parseResult ? parseResult.error : '');
|
||||||
|
|
||||||
|
function onGenerate() {
|
||||||
|
generated = generateObjectId();
|
||||||
|
}
|
||||||
|
|
||||||
|
import { copy as copyToClipboard } from '$lib/clipboard.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>ObjectId</h1>
|
||||||
|
|
||||||
|
<h2>Generate</h2>
|
||||||
|
<p>
|
||||||
|
<code>
|
||||||
|
<span class="ts" title="timestamp">{generated.slice(0, 8)}</span><span
|
||||||
|
class="rnd" title="random">{generated.slice(8, 18)}</span><span
|
||||||
|
class="cnt" title="counter">{generated.slice(18, 24)}</span>
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<button onclick={onGenerate}>New</button>
|
||||||
|
<button onclick={() => copyToClipboard(generated)}>Copy</button>
|
||||||
|
</p>
|
||||||
|
<p class="legend">
|
||||||
|
<span class="ts">■ timestamp</span>
|
||||||
|
<span class="rnd">■ random</span>
|
||||||
|
<span class="cnt">■ counter</span>
|
||||||
|
—
|
||||||
|
Generated: {new Date(parseInt(generated.slice(0, 8), 16) * 1000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Parse</h2>
|
||||||
|
<p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
size="26"
|
||||||
|
placeholder="507f1f77bcf86cd799439011"
|
||||||
|
bind:value={parseInput}
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if parseError}
|
||||||
|
<p class="error">{parseError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if parsed}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Timestamp (unix)</td><td><code>{parsed.timestamp}</code></td><td><button onclick={() => copyToClipboard(String(parsed.timestamp))}>copy</button></td></tr>
|
||||||
|
<tr><td>Date (UTC)</td><td><code>{parsed.date}</code></td><td><button onclick={() => copyToClipboard(parsed.date)}>copy</button></td></tr>
|
||||||
|
<tr><td>Date (local)</td><td><code>{parsed.dateLocal}</code></td><td><button onclick={() => copyToClipboard(parsed.dateLocal)}>copy</button></td></tr>
|
||||||
|
<tr><td>Random part</td><td><code class="rnd">{parsed.randomPart}</code></td><td><button onclick={() => copyToClipboard(parsed.randomPart)}>copy</button></td></tr>
|
||||||
|
<tr><td>Counter</td><td><code class="cnt">{parsed.counterPart}</code> ({parseInt(parsed.counterPart, 16)})</td><td><button onclick={() => copyToClipboard(parsed.counterPart)}>copy</button></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ts { color: var(--red); }
|
||||||
|
.rnd { color: var(--blue); }
|
||||||
|
.cnt { color: var(--green); }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
217
src/routes/string/+page.svelte
Normal file
217
src/routes/string/+page.svelte
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>String Transform</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string[]} */
|
||||||
|
function splitWords(s) {
|
||||||
|
return s
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.replace(/[\s_\-]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} s @returns {string} */
|
||||||
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||||
|
|
||||||
|
/** @type {Record<string, (s: string) => string>} */
|
||||||
|
const ops = {
|
||||||
|
lower: (s) => s.toLowerCase(),
|
||||||
|
upper: (s) => s.toUpperCase(),
|
||||||
|
snake: (s) => splitWords(s).map(w => w.toLowerCase()).join('_'),
|
||||||
|
screaming: (s) => splitWords(s).map(w => w.toUpperCase()).join('_'),
|
||||||
|
kebab: (s) => splitWords(s).map(w => w.toLowerCase()).join('-'),
|
||||||
|
camel: (s) => splitWords(s).map((w, i) => i === 0 ? w.toLowerCase() : capitalize(w)).join(''),
|
||||||
|
pascal: (s) => splitWords(s).map(w => capitalize(w)).join(''),
|
||||||
|
title: (s) => splitWords(s).map(w => capitalize(w)).join(' '),
|
||||||
|
reverse: (s) => s.split('').reverse().join(''),
|
||||||
|
trim: (s) => s.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {{ id: string, label: string }[]} */
|
||||||
|
const buttons = [
|
||||||
|
{ id: 'lower', label: 'lower' },
|
||||||
|
{ id: 'upper', label: 'UPPER' },
|
||||||
|
{ id: 'snake', label: 'snake_case' },
|
||||||
|
{ id: 'screaming', label: 'SCREAMING_SNAKE' },
|
||||||
|
{ id: 'kebab', label: 'kebab-case' },
|
||||||
|
{ id: 'camel', label: 'camelCase' },
|
||||||
|
{ id: 'pascal', label: 'PascalCase' },
|
||||||
|
{ id: 'title', label: 'Title Case' },
|
||||||
|
{ id: 'reverse', label: 'reverse' },
|
||||||
|
{ id: 'trim', label: 'trim' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let input = $state('');
|
||||||
|
let multi = $state(false);
|
||||||
|
/** @type {string | null} */
|
||||||
|
let op = $state(null);
|
||||||
|
|
||||||
|
/** @param {string} s @param {string} opId @returns {string} */
|
||||||
|
function applyOp(s, opId) {
|
||||||
|
const fn = ops[opId];
|
||||||
|
if (!fn) return s;
|
||||||
|
if (multi) return s.split('\n').map(line => line ? fn(line) : '').join('\n');
|
||||||
|
return fn(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = $derived(op && input ? applyOp(input, op) : '');
|
||||||
|
|
||||||
|
const stats = $derived.by(() => {
|
||||||
|
if (!input) return null;
|
||||||
|
const chars = input.length;
|
||||||
|
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
|
||||||
|
const lines = input.split('\n').length;
|
||||||
|
return { chars, words, lines };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>String</h1>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Input</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => { input = ''; op = null; }}>clear</button>
|
||||||
|
<button disabled={!input} onclick={() => copy(input)}>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
bind:value={input}
|
||||||
|
placeholder="Paste or type text…"
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="ops">
|
||||||
|
{#each buttons as b}
|
||||||
|
<button
|
||||||
|
class:active={op === b.id}
|
||||||
|
onclick={() => { op = op === b.id ? null : b.id; }}
|
||||||
|
>{b.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<label class="multi-label">
|
||||||
|
<input type="checkbox" bind:checked={multi} />
|
||||||
|
Multiple input (each line separately)
|
||||||
|
</label>
|
||||||
|
{#if stats}
|
||||||
|
<span class="stats">
|
||||||
|
<span>{stats.chars} chars</span>
|
||||||
|
<span>{stats.words} words</span>
|
||||||
|
<span>{stats.lines} lines</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if output}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>Result{op ? ` - ${buttons.find(b => b.id === op)?.label}` : ''}</span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button onclick={() => copy(output)}>copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="output">{output}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 35vh;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops button.active {
|
||||||
|
background: var(--active-bg);
|
||||||
|
color: var(--active-fg);
|
||||||
|
border-color: var(--active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
min-height: 3em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
src/routes/uuid/+page.svelte
Normal file
213
src/routes/uuid/+page.svelte
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
<svelte:head>
|
||||||
|
<title>UUID Tools</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<script lang="js">
|
||||||
|
import { copy } from '$lib/clipboard.js';
|
||||||
|
|
||||||
|
// 100-ns intervals from 1582-10-15 to 1970-01-01
|
||||||
|
const GREGORIAN_OFFSET = 122192928000000000n;
|
||||||
|
|
||||||
|
/** @param {Uint8Array} bytes @returns {string} */
|
||||||
|
function bytesToUUID(bytes) {
|
||||||
|
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv1() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const t = BigInt(Date.now()) * 10000n + GREGORIAN_OFFSET;
|
||||||
|
const tLow = Number(t & 0xffffffffn);
|
||||||
|
const tMid = Number((t >> 32n) & 0xffffn);
|
||||||
|
const tHi = Number((t >> 48n) & 0x0fffn);
|
||||||
|
bytes[0] = (tLow >> 24) & 0xff;
|
||||||
|
bytes[1] = (tLow >> 16) & 0xff;
|
||||||
|
bytes[2] = (tLow >> 8) & 0xff;
|
||||||
|
bytes[3] = tLow & 0xff;
|
||||||
|
bytes[4] = (tMid >> 8) & 0xff;
|
||||||
|
bytes[5] = tMid & 0xff;
|
||||||
|
bytes[6] = ((tHi >> 8) & 0x0f) | 0x10;
|
||||||
|
bytes[7] = tHi & 0xff;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
bytes[10] |= 0x01; // multicast bit → random node
|
||||||
|
return bytesToUUID(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv4() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv6() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const t = BigInt(Date.now()) * 10000n + GREGORIAN_OFFSET;
|
||||||
|
const tTop = t >> 12n;
|
||||||
|
bytes[0] = Number((tTop >> 40n) & 0xffn);
|
||||||
|
bytes[1] = Number((tTop >> 32n) & 0xffn);
|
||||||
|
bytes[2] = Number((tTop >> 24n) & 0xffn);
|
||||||
|
bytes[3] = Number((tTop >> 16n) & 0xffn);
|
||||||
|
bytes[4] = Number((tTop >> 8n) & 0xffn);
|
||||||
|
bytes[5] = Number( tTop & 0xffn);
|
||||||
|
bytes[6] = 0x60 | Number((t >> 8n) & 0x0fn);
|
||||||
|
bytes[7] = Number(t & 0xffn);
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
bytes[10] |= 0x01;
|
||||||
|
return bytesToUUID(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv7() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const ms = Date.now();
|
||||||
|
bytes[0] = (ms / 2**40) & 0xff;
|
||||||
|
bytes[1] = (ms / 2**32) & 0xff;
|
||||||
|
bytes[2] = (ms / 2**24) & 0xff;
|
||||||
|
bytes[3] = (ms / 2**16) & 0xff;
|
||||||
|
bytes[4] = (ms / 2**8) & 0xff;
|
||||||
|
bytes[5] = ms & 0xff;
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
return bytesToUUID(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* version: number,
|
||||||
|
* variant: string,
|
||||||
|
* timestamp?: number,
|
||||||
|
* date?: string,
|
||||||
|
* dateLocal?: string,
|
||||||
|
* randA?: string,
|
||||||
|
* randB?: string,
|
||||||
|
* clockSeq?: string,
|
||||||
|
* node?: string
|
||||||
|
* }} ParsedUUID
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {string} input @returns {ParsedUUID | { error: string }} */
|
||||||
|
function parseUUID(input) {
|
||||||
|
const s = input.trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s)) {
|
||||||
|
return { error: 'Invalid UUID format (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)' };
|
||||||
|
}
|
||||||
|
const hex = s.replace(/-/g, '');
|
||||||
|
const version = parseInt(hex[12], 16);
|
||||||
|
const variantNibble = parseInt(hex[16], 16);
|
||||||
|
|
||||||
|
let variant;
|
||||||
|
if ((variantNibble & 0x8) === 0) variant = 'NCS (0xxx)';
|
||||||
|
else if ((variantNibble & 0xc) === 0x8) variant = 'RFC 4122 (10xx)';
|
||||||
|
else if ((variantNibble & 0xe) === 0xc) variant = 'Microsoft (110x)';
|
||||||
|
else variant = 'Reserved (111x)';
|
||||||
|
|
||||||
|
/** @type {ParsedUUID} */
|
||||||
|
const result = { version, variant };
|
||||||
|
|
||||||
|
if (version === 7) {
|
||||||
|
const tsMs = parseInt(hex.slice(0, 12), 16);
|
||||||
|
const date = new Date(tsMs);
|
||||||
|
result.timestamp = tsMs;
|
||||||
|
result.date = date.toISOString();
|
||||||
|
result.dateLocal = date.toLocaleString();
|
||||||
|
result.randA = hex.slice(13, 16);
|
||||||
|
result.randB = hex.slice(16);
|
||||||
|
} else if (version === 1) {
|
||||||
|
const tHi = hex.slice(13, 16);
|
||||||
|
const tMid = hex.slice(8, 12);
|
||||||
|
const tLow = hex.slice(0, 8);
|
||||||
|
const t100ns = BigInt('0x' + tHi + tMid + tLow);
|
||||||
|
const unixMs = (t100ns - GREGORIAN_OFFSET) / 10000n;
|
||||||
|
const date = new Date(Number(unixMs));
|
||||||
|
result.timestamp = Number(unixMs);
|
||||||
|
result.date = date.toISOString();
|
||||||
|
result.dateLocal = date.toLocaleString();
|
||||||
|
result.clockSeq = hex.slice(16, 20);
|
||||||
|
result.node = hex.slice(20);
|
||||||
|
} else if (version === 6) {
|
||||||
|
const tHigh48 = hex.slice(0, 12);
|
||||||
|
const tLow12 = hex.slice(13, 16);
|
||||||
|
const t100ns = (BigInt('0x' + tHigh48) << 12n) | BigInt('0x' + tLow12);
|
||||||
|
const unixMs = (t100ns - GREGORIAN_OFFSET) / 10000n;
|
||||||
|
const date = new Date(Number(unixMs));
|
||||||
|
result.timestamp = Number(unixMs);
|
||||||
|
result.date = date.toISOString();
|
||||||
|
result.dateLocal = date.toLocaleString();
|
||||||
|
result.clockSeq = hex.slice(16, 20);
|
||||||
|
result.node = hex.slice(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
/** @type {{ label: string, uuid: string, gen: () => string }[]} */
|
||||||
|
let generators = $state([
|
||||||
|
{ label: 'v1', uuid: uuidv1(), gen: uuidv1 },
|
||||||
|
{ label: 'v4', uuid: uuidv4(), gen: uuidv4 },
|
||||||
|
{ label: 'v6', uuid: uuidv6(), gen: uuidv6 },
|
||||||
|
{ label: 'v7', uuid: uuidv7(), gen: uuidv7 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let parseInput = $state('');
|
||||||
|
const parseResult = $derived(parseInput.trim().length === 36 ? parseUUID(parseInput) : null);
|
||||||
|
const parsed = $derived(parseResult && !('error' in parseResult) ? parseResult : null);
|
||||||
|
const parseError = $derived(parseResult && 'error' in parseResult ? parseResult.error : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>UUID</h1>
|
||||||
|
|
||||||
|
<h2>Generate</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each generators as g}
|
||||||
|
<tr>
|
||||||
|
<td>{g.label}</td>
|
||||||
|
<td><code>{g.uuid}</code></td>
|
||||||
|
<td><button onclick={() => { g.uuid = g.gen(); }}>new</button></td>
|
||||||
|
<td><button onclick={() => copy(g.uuid)}>copy</button></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Parse / Inspect</h2>
|
||||||
|
<p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={parseInput}
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
size="38"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if parseError}
|
||||||
|
<p class="error">{parseError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if parsed}
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Version</td><td><b>{parsed.version}</b></td><td></td></tr>
|
||||||
|
<tr><td>Variant</td><td>{parsed.variant}</td><td></td></tr>
|
||||||
|
{#if parsed.date}
|
||||||
|
<tr><td>Timestamp (ms)</td><td><code>{parsed.timestamp}</code></td><td><button onclick={() => copy(String(parsed.timestamp))}>copy</button></td></tr>
|
||||||
|
<tr><td>Date (UTC)</td><td><code>{parsed.date}</code></td><td><button onclick={() => copy(parsed.date ?? '')}>copy</button></td></tr>
|
||||||
|
<tr><td>Date (local)</td><td><code>{parsed.dateLocal}</code></td><td><button onclick={() => copy(parsed.dateLocal ?? '')}>copy</button></td></tr>
|
||||||
|
{/if}
|
||||||
|
{#if parsed.randA}
|
||||||
|
<tr><td>rand_a (12 bits)</td><td><code>{parsed.randA}</code></td><td></td></tr>
|
||||||
|
<tr><td>rand_b (62 bits)</td><td><code>{parsed.randB}</code></td><td></td></tr>
|
||||||
|
{/if}
|
||||||
|
{#if parsed.clockSeq}
|
||||||
|
<tr><td>Clock seq</td><td><code>{parsed.clockSeq}</code></td><td></td></tr>
|
||||||
|
<tr><td>Node</td><td><code>{parsed.node}</code></td><td><button onclick={() => copy(parsed.node ?? '')}>copy</button></td></tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error { color: var(--err); }
|
||||||
|
</style>
|
||||||
1
static/favicon.svg
Normal file
1
static/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
13
svelte.config.js
Normal file
13
svelte.config.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue