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
82 changes: 82 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"react-dom": "^18.3.1",
"signature_pad": "^5.1.3",
"smol-toml": "^1.7.0",
"sql-formatter": "^15.8.2",
"tailwindcss": "^3.4.19",
"turndown": "^7.2.4",
"upscaler": "^1.0.0",
Expand Down
134 changes: 134 additions & 0 deletions src/islands/dev/SqlFormat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
import { Download, Sparkles } from 'lucide-react';
import { TextArea } from '@/components/ui/TextArea';
import { Button } from '@/components/ui/Button';
import { CopyButton } from '@/components/ui/CopyButton';
import { Alert } from '@/components/ui/Alert';
import { downloadService } from '@/services/download.service';
import type { KeywordCase, IndentKind, SqlFormatOptions } from '@/tools/dev/sql-format.lib';
import type { Lang } from '@/i18n/config';

const DIALECTS: { value: string; label: string }[] = [
{ value: 'sql', label: 'Standard SQL' },
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' },
{ value: 'sqlite', label: 'SQLite' },
{ value: 'tsql', label: 'SQL Server (T-SQL)' },
{ value: 'plsql', label: 'Oracle (PL/SQL)' },
{ value: 'bigquery', label: 'BigQuery' },
{ value: 'snowflake', label: 'Snowflake' },
{ value: 'redshift', label: 'Redshift' },
{ value: 'spark', label: 'Spark SQL' },
{ value: 'duckdb', label: 'DuckDB' },
{ value: 'clickhouse', label: 'ClickHouse' },
{ value: 'db2', label: 'Db2' },
{ value: 'hive', label: 'Hive' },
{ value: 'trino', label: 'Trino' },
];

const CASES: KeywordCase[] = ['upper', 'lower', 'preserve'];
const INDENTS: IndentKind[] = ['2', '4', 'tab'];

const EXAMPLE = "select u.id, u.name, count(o.id) as orders from users u left join orders o on o.user_id=u.id where u.active=true and o.created_at > '2024-01-01' group by u.id, u.name having count(o.id) > 3 order by orders desc limit 10;";

const TR: Record<Lang, {
intro: string; input: string; placeholder: string; output: string; dialect: string;
keywordCase: string; indent: string; caseLabels: Record<KeywordCase, string>; indentLabels: Record<IndentKind, string>;
example: string; download: string; errParse: string; empty: string;
}> = {
en: {
intro: 'Format and beautify SQL queries in your browser — pick your database dialect, keyword case and indentation. Everything runs on your device; nothing is uploaded.',
input: 'SQL', placeholder: 'Paste your SQL query here…', output: 'Formatted', dialect: 'Dialect',
keywordCase: 'Keywords', indent: 'Indent',
caseLabels: { upper: 'UPPER', lower: 'lower', preserve: 'Keep' },
indentLabels: { '2': '2 spaces', '4': '4 spaces', tab: 'Tab' },
example: 'Load example', download: 'Download .sql', errParse: 'Could not parse this SQL — check the query and the selected dialect.', empty: 'Formatted SQL will appear here.',
},
id: {
intro: 'Format dan rapikan kueri SQL di browser Anda — pilih dialek basis data, huruf kata kunci, dan indentasi. Semuanya berjalan di perangkat Anda; tidak ada yang diunggah.',
input: 'SQL', placeholder: 'Tempel kueri SQL Anda di sini…', output: 'Terformat', dialect: 'Dialek',
keywordCase: 'Kata kunci', indent: 'Indentasi',
caseLabels: { upper: 'BESAR', lower: 'kecil', preserve: 'Biarkan' },
indentLabels: { '2': '2 spasi', '4': '4 spasi', tab: 'Tab' },
example: 'Muat contoh', download: 'Unduh .sql', errParse: 'Tidak dapat mengurai SQL ini — periksa kueri dan dialek yang dipilih.', empty: 'SQL terformat akan muncul di sini.',
},
};

export default function SqlFormat({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [input, setInput] = useState('');
const [language, setLanguage] = useState('sql');
const [keywordCase, setKeywordCase] = useState<KeywordCase>('upper');
const [indent, setIndent] = useState<IndentKind>('2');
const [output, setOutput] = useState('');
const [error, setError] = useState('');
const fmtRef = useRef<((sql: string, opts: SqlFormatOptions) => string) | null>(null);

useEffect(() => {
let cancelled = false;
(async () => {
if (!input.trim()) { setOutput(''); setError(''); return; }
try {
if (!fmtRef.current) fmtRef.current = (await import('@/tools/dev/sql-format.lib')).formatSql;
if (cancelled) return;
setOutput(fmtRef.current(input, { language, keywordCase, indent }));
setError('');
} catch {
if (!cancelled) setError(t.errParse);
}
})();
return () => { cancelled = true; };
}, [input, language, keywordCase, indent, t.errParse]);

const download = () => downloadService.download(new Blob([output], { type: 'application/sql' }), 'formatted.sql');

const segClass = (active: boolean) =>
`border-2 px-3 py-1 text-sm font-medium transition-all ${active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal'}`;

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

<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.dialect}</span>
<select value={language} onChange={(e) => setLanguage(e.target.value)} className="border-2 border-border bg-background px-2 py-1.5 text-sm">
{DIALECTS.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
<div className="space-y-1 text-sm">
<span className="block font-semibold">{t.keywordCase}</span>
<div className="flex gap-1">
{CASES.map((c) => <button key={c} onClick={() => setKeywordCase(c)} aria-pressed={keywordCase === c} className={segClass(keywordCase === c)}>{t.caseLabels[c]}</button>)}
</div>
</div>
<div className="space-y-1 text-sm">
<span className="block font-semibold">{t.indent}</span>
<div className="flex gap-1">
{INDENTS.map((i) => <button key={i} onClick={() => setIndent(i)} aria-pressed={indent === i} className={segClass(indent === i)}>{t.indentLabels[i]}</button>)}
</div>
</div>
</div>

<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="mr-auto text-sm font-semibold">{t.input}</span>
<Button variant="ghost" onClick={() => setInput(EXAMPLE)}><Sparkles className="h-4 w-4" /> {t.example}</Button>
</div>
<TextArea value={input} onChange={(e) => setInput(e.target.value)} placeholder={t.placeholder} rows={16} spellCheck={false} />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="mr-auto text-sm font-semibold">{t.output}</span>
<CopyButton value={output} />
<Button variant="secondary" onClick={download} disabled={!output}><Download className="h-4 w-4" /> {t.download}</Button>
</div>
{error && <Alert variant="error">{error}</Alert>}
<TextArea value={output} readOnly rows={16} spellCheck={false} placeholder={t.empty} />
</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 @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config';
* a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data.
*/
const en: Record<string, ToolSeoContent> = {
'sql-format': {
title: 'Free SQL Formatter — Beautify SQL Queries Online',
description: 'A free SQL formatter to beautify and pretty-print SQL queries in your browser — PostgreSQL, MySQL, SQLite, BigQuery and more. 100% private; nothing is uploaded.',
intro: 'This free SQL formatter beautifies and pretty-prints your SQL queries right in your browser. Pick your database dialect, keyword case and indentation and get clean, readable SQL instantly. It runs entirely on your device, so your queries are never uploaded.',
howTo: [
'Paste your SQL query into the box (or load the example).',
'Choose your dialect — PostgreSQL, MySQL, SQLite, BigQuery and more.',
'Set keyword case (UPPER, lower or keep) and indentation (2, 4 spaces or tab).',
'Copy the formatted SQL or download it as a .sql file.',
],
faqs: [
{ q: 'Is my SQL uploaded to a server?', a: 'No. Formatting happens entirely in your browser with JavaScript. Your queries never leave your device, so it is safe for proprietary or sensitive SQL.' },
{ q: 'Which SQL dialects are supported?', a: 'Standard SQL plus PostgreSQL, MySQL, MariaDB, SQLite, SQL Server (T-SQL), Oracle (PL/SQL), BigQuery, Snowflake, Redshift, Spark, DuckDB, ClickHouse, Db2, Hive and Trino.' },
{ q: 'Can it uppercase or lowercase keywords?', a: 'Yes — choose UPPER to capitalise keywords like SELECT and FROM, lower to make them lowercase, or Keep to leave them as they are.' },
{ q: 'Does it validate or run my query?', a: 'No. It only formats the text for readability; it does not execute, validate or connect to any database.' },
],
},
'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 @@ -1410,6 +1427,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'sql-format': {
title: 'Pemformat SQL Gratis — Rapikan Kueri SQL Online',
description: 'Pemformat SQL gratis untuk merapikan dan mempercantik kueri SQL di browser Anda — PostgreSQL, MySQL, SQLite, BigQuery, dan lainnya. 100% privat; tidak ada yang diunggah.',
intro: 'Pemformat SQL gratis ini merapikan dan mempercantik kueri SQL Anda langsung di browser. Pilih dialek basis data, huruf kata kunci, dan indentasi untuk mendapatkan SQL yang bersih dan mudah dibaca secara instan. Semuanya berjalan di perangkat Anda, jadi kueri Anda tidak pernah diunggah.',
howTo: [
'Tempel kueri SQL Anda ke dalam kotak (atau muat contoh).',
'Pilih dialek Anda — PostgreSQL, MySQL, SQLite, BigQuery, dan lainnya.',
'Atur huruf kata kunci (BESAR, kecil, atau biarkan) dan indentasi (2, 4 spasi, atau tab).',
'Salin SQL terformat atau unduh sebagai berkas .sql.',
],
faqs: [
{ q: 'Apakah SQL saya diunggah ke server?', a: 'Tidak. Pemformatan terjadi sepenuhnya di browser Anda dengan JavaScript. Kueri Anda tidak pernah meninggalkan perangkat, jadi aman untuk SQL rahasia atau sensitif.' },
{ q: 'Dialek SQL apa saja yang didukung?', a: 'Standard SQL ditambah PostgreSQL, MySQL, MariaDB, SQLite, SQL Server (T-SQL), Oracle (PL/SQL), BigQuery, Snowflake, Redshift, Spark, DuckDB, ClickHouse, Db2, Hive, dan Trino.' },
{ q: 'Bisakah membuat kata kunci huruf besar atau kecil?', a: 'Ya — pilih BESAR untuk mengapitalkan kata kunci seperti SELECT dan FROM, kecil untuk membuatnya huruf kecil, atau Biarkan agar tetap seperti aslinya.' },
{ q: 'Apakah memvalidasi atau menjalankan kueri saya?', a: 'Tidak. Ini hanya memformat teks agar mudah dibaca; tidak menjalankan, memvalidasi, atau terhubung ke basis data apa pun.' },
],
},
'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
11 changes: 11 additions & 0 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/dev/CompareLists'),
status: 'beta'
},
{
id: 'sql-format',
name: 'SQL Formatter',
category: 'Dev',
route: '/tools/sql-format',
keywords: ['sql', 'format', 'formatter', 'beautify', 'prettify', 'pretty', 'query', 'postgresql', 'mysql', 'sqlite', 'bigquery', 'database'],
icon: Database,
summary: 'Format and beautify SQL queries (PostgreSQL, MySQL, and more)',
load: () => import('@/islands/dev/SqlFormat'),
status: 'beta'
},
{
id: 'docx-viewer',
name: 'Word (DOCX) Viewer',
Expand Down
50 changes: 50 additions & 0 deletions src/tools/dev/sql-format.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { formatSql, DIALECTS } from './sql-format.lib';

describe('formatSql', () => {
it('pretty-prints and upper-cases keywords', () => {
const out = formatSql('select id,name from users where id=1', { language: 'postgresql', keywordCase: 'upper', indent: '2' });
expect(out).toContain('SELECT');
expect(out).toContain('FROM');
expect(out).toContain('WHERE');
expect(out.split('\n').length).toBeGreaterThan(1); // multi-line
expect(out).toContain('\n '); // 2-space indent
});

it('lower-cases keywords when asked', () => {
const out = formatSql('SELECT * FROM t', { language: 'sql', keywordCase: 'lower', indent: '2' });
expect(out).toContain('select');
expect(out).toContain('from');
expect(out).not.toContain('SELECT');
});

it('indents with a tab when indent is "tab"', () => {
const out = formatSql('SELECT * FROM t', { language: 'sql', keywordCase: 'upper', indent: 'tab' });
expect(out).toContain('\t');
});

it('indents with 4 spaces when indent is "4"', () => {
const out = formatSql('SELECT a FROM t', { language: 'sql', keywordCase: 'upper', indent: '4' });
expect(out).toContain('\n ');
});

it('returns empty string for blank input', () => {
expect(formatSql('', DEFAULT())).toBe('');
expect(formatSql(' \n ', DEFAULT())).toBe('');
});

it('falls back to standard SQL for an unknown dialect', () => {
const out = formatSql('select 1', { language: 'not-a-dialect', keywordCase: 'upper', indent: '2' });
expect(out).toContain('SELECT');
});

it('exposes the supported dialect list', () => {
expect(DIALECTS).toContain('postgresql');
expect(DIALECTS).toContain('mysql');
expect(DIALECTS.length).toBeGreaterThan(5);
});
});

function DEFAULT() {
return { language: 'sql', keywordCase: 'upper', indent: '2' } as const;
}
Loading
Loading