Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions e2e/tools/sort-lines.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect, type Page } from '@playwright/test';

// Tool islands lazy-load via ToolHost; on a cold dev server they hydrate a few
// seconds after navigation, and a fill() before that is discarded when React
// mounts with empty initial state. Re-fill until the reactive output confirms
// the island is live.
async function typeWhenReady(page: Page, value: string, expected: string) {
// Wait for the lazy island chunk to load + hydrate before interacting, else a
// fill() is discarded when React mounts with empty initial state.
await page.waitForLoadState('networkidle').catch(() => {});
const input = page.getByPlaceholder(/Paste lines/);
const output = page.locator('textarea[readonly]');
await input.waitFor({ state: 'visible' });
await expect(async () => {
await input.fill(value);
await expect(output).toHaveValue(expected, { timeout: 2000 });
}).toPass({ timeout: 30_000 });
}

test('sorts lines and reacts to the order control', async ({ page }) => {
await page.goto('/tools/sort-lines');
await typeWhenReady(page, 'banana\napple\ncherry', 'apple\nbanana\ncherry');

await page.locator('select:has(option[value="reverse"])').selectOption('desc');
await expect(page.locator('textarea[readonly]')).toHaveValue('cherry\nbanana\napple');
});

test('sorts env-style lines by key with the option toggles', async ({ page }) => {
await page.goto('/tools/sort-lines');
// Default ascending already orders these by first letter (a < d < z).
await typeWhenReady(page, 'ZONE=us\napi_key=1\nDB_HOST=x', 'api_key=1\nDB_HOST=x\nZONE=us');

// Toggling sort-by-key + ignore-case keeps a valid, stable ordering.
await page.getByLabel('Sort by key (before = or :)').check();
await page.getByLabel('Ignore case').check();
await expect(page.locator('textarea[readonly]')).toHaveValue('api_key=1\nDB_HOST=x\nZONE=us');
});
122 changes: 122 additions & 0 deletions src/islands/dev/SortLines.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { DownloadTextButton } from '@/components/ui/DownloadTextButton';
import { sortTextLines, type SortLinesOptions } from '@/tools/dev/sort-lines.lib';
import type { Lang } from '@/i18n/config';

type Dir = 'asc' | 'desc' | 'reverse';

const TR: Record<Lang, {
intro: string; input: string; output: string; placeholder: string; direction: string;
asc: string; desc: string; reverse: string; options: string;
caseInsensitive: string; natural: string; byKey: string; dedupe: string; trimEach: string; dropBlanks: string;
trimChars: string; trimCharsPh: string; count: (n: number) => string;
}> = {
en: {
intro: 'Reorder lines of text — ascending, descending or reversed — with options for case, natural (numeric) order, and sorting by the key before = or : (handy for env vars and k8s/Vault secrets). Everything runs in your browser; nothing is uploaded.',
input: 'Lines', output: 'Sorted', placeholder: 'Paste lines to sort…\nAPI_KEY=1\nDB_HOST=localhost',
direction: 'Order', asc: 'A → Z', desc: 'Z → A', reverse: 'Reverse (no sort)',
options: 'Options',
caseInsensitive: 'Ignore case', natural: 'Natural order (2 before 10)',
byKey: 'Sort by key (before = or :)', dedupe: 'Remove duplicate lines',
trimEach: 'Trim each line', dropBlanks: 'Remove blank lines',
trimChars: 'Trim characters', trimCharsPh: 'e.g. "\',',
count: (n) => `${n} line${n === 1 ? '' : 's'}`,
},
id: {
intro: 'Urutkan baris teks — menaik, menurun, atau dibalik — dengan opsi case, urutan natural (angka), dan urutkan berdasarkan key sebelum = atau : (berguna untuk env var dan secret k8s/Vault). Semua berjalan di browser Anda; tidak ada yang diunggah.',
input: 'Baris', output: 'Terurut', placeholder: 'Tempel baris untuk diurutkan…\nAPI_KEY=1\nDB_HOST=localhost',
direction: 'Urutan', asc: 'A → Z', desc: 'Z → A', reverse: 'Balik (tanpa urut)',
options: 'Opsi',
caseInsensitive: 'Abaikan huruf besar/kecil', natural: 'Urutan natural (2 sebelum 10)',
byKey: 'Urutkan berdasarkan key (sebelum = atau :)', dedupe: 'Hapus baris duplikat',
trimEach: 'Rapikan tiap baris', dropBlanks: 'Hapus baris kosong',
trimChars: 'Pangkas karakter', trimCharsPh: 'mis. "\',',
count: (n) => `${n} baris`,
},
};

export default function SortLines({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [direction, setDirection] = useState<Dir>('asc');
const [flags, setFlags] = useState<Omit<SortLinesOptions, 'direction' | 'trimChars'>>({});
const [trimChars, setTrimChars] = useState('');

const output = useMemo(
() => sortTextLines(text, { ...flags, direction, trimChars }),
[text, flags, direction, trimChars],
);
const outCount = output === '' ? 0 : output.split('\n').length;

const toggle = (key: keyof typeof flags) => setFlags(p => ({ ...p, [key]: !p[key] }));

const CHECKS: { key: keyof typeof flags; label: string }[] = [
{ key: 'caseInsensitive', label: t.caseInsensitive },
{ key: 'natural', label: t.natural },
{ key: 'byKey', label: t.byKey },
{ key: 'dedupe', label: t.dedupe },
{ key: 'trimEach', label: t.trimEach },
{ key: 'dropBlanks', label: t.dropBlanks },
];

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="flex flex-wrap items-end gap-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-semibold">{t.direction}</span>
<select
value={direction}
onChange={e => setDirection(e.target.value as Dir)}
className="h-9 border-2 border-border bg-muted px-2 outline-none focus:shadow-brutal-sm"
>
<option value="asc">{t.asc}</option>
<option value="desc">{t.desc}</option>
<option value="reverse">{t.reverse}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-semibold">{t.trimChars}</span>
<input
value={trimChars}
onChange={e => setTrimChars(e.target.value)}
placeholder={t.trimCharsPh}
className="h-9 w-40 border-2 border-border bg-muted px-2 font-mono outline-none focus:shadow-brutal-sm"
/>
</label>
</div>

<div className="space-y-1">
<span className="block text-sm font-semibold">{t.options}</span>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{CHECKS.map(c => (
<label key={c.key} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={!!flags[c.key]} onChange={() => toggle(c.key)} className="h-4 w-4 accent-accent" />
{c.label}
</label>
))}
</div>
</div>

<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-1">
<span className="block text-sm font-semibold">{t.input}</span>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={14} placeholder={t.placeholder} />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.output} <span className="font-normal text-muted-foreground">· {t.count(outCount)}</span></span>
<div className="flex gap-2">
<DownloadTextButton text={output} filename="sorted.txt" />
<CopyButton value={output} />
</div>
</div>
<TextArea value={output} readOnly rows={14} />
</div>
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,23 @@ const en: Record<string, ToolSeoContent> = {
{ q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the scrubber works with no internet connection.' },
],
},
'sort-lines': {
title: 'Sort Text Lines — Alphabetize, Reverse & Sort by Key Online',
description: 'Sort lines of text A→Z, Z→A or reversed — case-insensitive, natural (numeric) order, or by the key before = or :. Great for env vars and secrets. Free, private, nothing uploaded.',
intro: 'Paste lines and reorder them instantly — ascending, descending, or reversed. Sort case-insensitively, use natural order so item2 comes before item10, or sort by the key (the text before the first = or :) so KEY=value lines line up for a clean side-by-side compare of env files, k8s secrets and Vault output. You can also dedupe, trim each line, drop blank lines and strip stray characters. It all runs in your browser, so sensitive values are never uploaded.',
howTo: [
'Paste your lines into the Lines box (one entry per line).',
'Choose the order: A→Z, Z→A, or Reverse (flip without sorting).',
'Turn on options as needed — ignore case, natural order, sort by key (before = or :), remove duplicates, trim lines, drop blanks, or trim specific characters.',
'Copy the sorted result or download it as a .txt file.',
],
faqs: [
{ q: 'Is my text uploaded?', a: 'No. All sorting happens in your browser with JavaScript. Your lines never leave your device, so it is safe for env vars, k8s secrets and other sensitive values.' },
{ q: 'Can I sort env vars or secrets by their key?', a: 'Yes. Turn on “Sort by key” to order by the text before the first = or :, so KEY=value and KEY: value lines are ordered by KEY. Sort both files the same way and they line up for a line-by-line diff.' },
{ q: 'What is natural order?', a: 'Natural (numeric) order sorts embedded numbers by value, so item2 comes before item10 instead of the plain alphabetical item10 before item2.' },
{ q: 'Can it remove duplicates and blank lines?', a: 'Yes. Toggle “Remove duplicate lines”, “Remove blank lines” and “Trim each line”, and use “Trim characters” to strip stray quotes or commas from the ends of each line.' },
],
},
'compare-lists': {
title: 'Compare Two Lists — Merge, Dedupe & Diff Lines',
description: 'Compare two lists of lines online: merge and remove duplicates, subtract one list from another, or find common lines. Free, private and instant — nothing is uploaded.',
Expand Down Expand Up @@ -4961,6 +4978,23 @@ const id: Record<string, ToolSeoContent> = {
{ q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat pembersih bekerja tanpa koneksi internet.' },
],
},
'sort-lines': {
title: 'Urutkan Baris Teks — Alfabet, Balik & Urut berdasarkan Key',
description: 'Urutkan baris teks A→Z, Z→A atau dibalik — abaikan huruf besar/kecil, urutan natural (angka), atau berdasarkan key sebelum = atau :. Cocok untuk env var dan secret. Gratis, privat, tidak diunggah.',
intro: 'Tempel baris dan urutkan seketika — menaik, menurun, atau dibalik. Urutkan tanpa memandang huruf besar/kecil, pakai urutan natural agar item2 sebelum item10, atau urutkan berdasarkan key (teks sebelum = atau : pertama) sehingga baris KEY=value sejajar untuk membandingkan berkas env, secret k8s, dan output Vault secara berdampingan. Anda juga bisa hapus duplikat, rapikan tiap baris, hapus baris kosong, dan pangkas karakter yang mengganggu. Semua berjalan di browser Anda, jadi nilai sensitif tidak pernah diunggah.',
howTo: [
'Tempel baris Anda ke kotak Baris (satu entri per baris).',
'Pilih urutan: A→Z, Z→A, atau Balik (membalik tanpa mengurutkan).',
'Aktifkan opsi sesuai kebutuhan — abaikan huruf besar/kecil, urutan natural, urutkan berdasarkan key (sebelum = atau :), hapus duplikat, rapikan baris, hapus baris kosong, atau pangkas karakter tertentu.',
'Salin hasil terurut atau unduh sebagai berkas .txt.',
],
faqs: [
{ q: 'Apakah teks saya diunggah?', a: 'Tidak. Semua pengurutan terjadi di browser Anda dengan JavaScript. Baris Anda tidak pernah meninggalkan perangkat, jadi aman untuk env var, secret k8s, dan nilai sensitif lainnya.' },
{ q: 'Bisakah mengurutkan env var atau secret berdasarkan key-nya?', a: 'Ya. Aktifkan “Urutkan berdasarkan key” untuk mengurutkan berdasarkan teks sebelum = atau : pertama, sehingga baris KEY=value dan KEY: value diurutkan berdasarkan KEY. Urutkan kedua berkas dengan cara yang sama dan keduanya sejajar untuk diff baris per baris.' },
{ q: 'Apa itu urutan natural?', a: 'Urutan natural (angka) mengurutkan angka di dalam teks berdasarkan nilainya, jadi item2 sebelum item10, bukan alfabet biasa item10 sebelum item2.' },
{ q: 'Bisakah menghapus duplikat dan baris kosong?', a: 'Ya. Aktifkan “Hapus baris duplikat”, “Hapus baris kosong”, dan “Rapikan tiap baris”, serta gunakan “Pangkas karakter” untuk membuang tanda kutip atau koma yang mengganggu di ujung tiap baris.' },
],
},
'compare-lists': {
title: 'Bandingkan Dua Daftar — Gabung, Hapus Duplikat & Diff',
description: 'Bandingkan dua daftar baris secara online: gabung dan hapus duplikat, kurangi satu daftar dari yang lain, atau temukan baris yang sama. Gratis, privat, instan — tidak ada yang diunggah.',
Expand Down
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Apple, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock, Scale, Flame, GraduationCap } from 'lucide-react';
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Apple, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock, Scale, Flame, GraduationCap, ArrowDownAZ } from 'lucide-react';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -179,6 +179,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/dev/CompareLists'),
status: 'beta'
},
{
id: 'sort-lines',
name: 'Sort Text Lines',
category: 'Dev',
route: '/tools/sort-lines',
keywords: ['sort lines', 'sort text', 'alphabetize', 'order lines', 'ascending', 'descending', 'reverse lines', 'natural sort', 'sort env', 'sort by key', 'dedupe', 'reorder', 'line sorter'],
icon: ArrowDownAZ,
summary: 'Sort lines A→Z, Z→A or reversed — by key, case-insensitive, natural order',
load: () => import('@/islands/dev/SortLines'),
status: 'beta'
},
{
id: 'sql-format',
name: 'SQL Formatter',
Expand Down
42 changes: 42 additions & 0 deletions src/tools/dev/sort-lines.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { sortTextLines } from './sort-lines.lib';

describe('sortTextLines', () => {
it('sorts ascending by default', () => {
expect(sortTextLines('b\na\nc')).toBe('a\nb\nc');
});
it('sorts descending', () => {
expect(sortTextLines('b\na\nc', { direction: 'desc' })).toBe('c\nb\na');
});
it('reverses without sorting', () => {
expect(sortTextLines('b\na\nc', { direction: 'reverse' })).toBe('c\na\nb');
});
it('case-insensitive ordering', () => {
expect(sortTextLines('B\na\nC', { caseInsensitive: true })).toBe('a\nB\nC');
});
it('natural (numeric) ordering', () => {
expect(sortTextLines('item10\nitem2\nitem1', { natural: true })).toBe('item1\nitem2\nitem10');
});
it('sorts by the key before = or :', () => {
expect(sortTextLines('B=2\nA=1\nC=3', { byKey: true })).toBe('A=1\nB=2\nC=3');
expect(sortTextLines('B: two\nA: one', { byKey: true })).toBe('A: one\nB: two');
});
it('dedupes lines', () => {
expect(sortTextLines('a\na\nb', { dedupe: true })).toBe('a\nb');
});
it('trims each line and drops blanks', () => {
expect(sortTextLines(' b \n\n a ', { trimEach: true, dropBlanks: true })).toBe('a\nb');
});
it('trims specific characters from both ends', () => {
expect(sortTextLines('"b"\n"a"', { trimChars: '"' })).toBe('a\nb');
expect(sortTextLines("A=1,\nB=2,", { trimChars: ',', byKey: true })).toBe('A=1\nB=2');
});
it('combines env-style: sort by key, case-insensitive, dedupe', () => {
const input = 'db_host=x\nAPI_KEY=1\napi_key=1\nZONE=us';
expect(sortTextLines(input, { byKey: true, caseInsensitive: true, dedupe: true }))
.toBe('API_KEY=1\napi_key=1\ndb_host=x\nZONE=us');
});
it('returns empty for empty input', () => {
expect(sortTextLines('', { dropBlanks: true })).toBe('');
});
});
Loading
Loading