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
125 changes: 125 additions & 0 deletions src/islands/dev/CompareLists.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useMemo, useState } from 'react';
import { ArrowLeftRight, Download } from 'lucide-react';
import { TextArea } from '@/components/ui/TextArea';
import { Button } from '@/components/ui/Button';
import { CopyButton } from '@/components/ui/CopyButton';
import { downloadService } from '@/services/download.service';
import { compareLines, type LineSetMode, type LineSetOptions } from '@/tools/dev/lineset.lib';
import type { Lang } from '@/i18n/config';

const MODE_IDS: LineSetMode[] = ['union', 'difference', 'differenceB', 'intersection', 'symmetric', 'duplicates'];

const TR: Record<Lang, {
intro: string; listA: string; listB: string; placeholderA: string; placeholderB: string;
swap: string; result: string; resultCount: (n: number) => string; lineCount: (n: number) => string;
download: string; empty: string; optionsLabel: string;
opts: Record<keyof LineSetOptions, string>;
modes: Record<LineSetMode, { label: string; hint: string }>;
}> = {
en: {
intro: 'Compare two lists of lines and combine them with set operations β€” merge and dedupe, subtract one from the other, find what they share, and more. Everything runs in your browser; nothing is uploaded.',
listA: 'List A', listB: 'List B',
placeholderA: 'Paste the first list, one item per line…', placeholderB: 'Paste the second list, one item per line…',
swap: 'Swap A ↔ B', result: 'Result', resultCount: (n) => `${n.toLocaleString()} line${n === 1 ? '' : 's'}`,
lineCount: (n) => `${n.toLocaleString()} line${n === 1 ? '' : 's'}`, download: 'Download .txt',
empty: 'The result is empty.', optionsLabel: 'Options',
opts: { caseInsensitive: 'Ignore case', trim: 'Trim whitespace', ignoreBlank: 'Ignore blank lines', sort: 'Sort result' },
modes: {
union: { label: 'Merge & dedupe', hint: 'All unique lines from both lists (A βˆͺ B)' },
difference: { label: 'In A, not in B', hint: 'Remove B’s lines from A (A βˆ’ B)' },
differenceB: { label: 'In B, not in A', hint: 'Remove A’s lines from B (B βˆ’ A)' },
intersection: { label: 'Common to both', hint: 'Lines that appear in both lists (A ∩ B)' },
symmetric: { label: 'In only one', hint: 'Lines in just one of the lists (A β–³ B)' },
duplicates: { label: 'Duplicates', hint: 'Lines that appear 2+ times across both lists' },
},
},
id: {
intro: 'Bandingkan dua daftar baris dan gabungkan dengan operasi himpunan β€” gabung dan hapus duplikat, kurangi satu dari yang lain, temukan yang sama, dan lainnya. Semuanya berjalan di browser Anda; tidak ada yang diunggah.',
listA: 'Daftar A', listB: 'Daftar B',
placeholderA: 'Tempel daftar pertama, satu item per baris…', placeholderB: 'Tempel daftar kedua, satu item per baris…',
swap: 'Tukar A ↔ B', result: 'Hasil', resultCount: (n) => `${n.toLocaleString()} baris`,
lineCount: (n) => `${n.toLocaleString()} baris`, download: 'Unduh .txt',
empty: 'Hasilnya kosong.', optionsLabel: 'Opsi',
opts: { caseInsensitive: 'Abaikan huruf besar/kecil', trim: 'Pangkas spasi', ignoreBlank: 'Abaikan baris kosong', sort: 'Urutkan hasil' },
modes: {
union: { label: 'Gabung & hapus duplikat', hint: 'Semua baris unik dari kedua daftar (A βˆͺ B)' },
difference: { label: 'Di A, tidak di B', hint: 'Hapus baris B dari A (A βˆ’ B)' },
differenceB: { label: 'Di B, tidak di A', hint: 'Hapus baris A dari B (B βˆ’ A)' },
intersection: { label: 'Sama di keduanya', hint: 'Baris yang muncul di kedua daftar (A ∩ B)' },
symmetric: { label: 'Hanya di salah satu', hint: 'Baris yang hanya ada di salah satu daftar (A β–³ B)' },
duplicates: { label: 'Duplikat', hint: 'Baris yang muncul 2+ kali di kedua daftar' },
},
},
};

const nonEmptyLines = (s: string) => (s.length ? s.split(/\r\n|\r|\n/).filter((l) => l.trim() !== '').length : 0);

export default function CompareLists({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [a, setA] = useState('');
const [b, setB] = useState('');
const [mode, setMode] = useState<LineSetMode>('union');
const [opts, setOpts] = useState<LineSetOptions>({ trim: true });

const result = useMemo(() => compareLines(a, b, mode, opts), [a, b, mode, opts]);
const resultText = useMemo(() => result.lines.join('\n'), [result]);

const toggle = (k: keyof LineSetOptions) => setOpts((o) => ({ ...o, [k]: !o[k] }));
const swap = () => { setA(b); setB(a); };
const download = () => downloadService.download(new Blob([resultText], { type: 'text/plain' }), 'compare-result.txt');

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

<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<TextArea label={`${t.listA} Β· ${t.lineCount(nonEmptyLines(a))}`} value={a} onChange={(e) => setA(e.target.value)} placeholder={t.placeholderA} rows={10} spellCheck={false} />
</div>
<div className="space-y-1">
<TextArea label={`${t.listB} Β· ${t.lineCount(nonEmptyLines(b))}`} value={b} onChange={(e) => setB(e.target.value)} placeholder={t.placeholderB} rows={10} spellCheck={false} />
</div>
</div>

<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" onClick={swap}><ArrowLeftRight className="h-4 w-4" /> {t.swap}</Button>
</div>

<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{MODE_IDS.map((id) => (
<button
key={id}
onClick={() => setMode(id)}
title={t.modes[id].hint}
aria-pressed={mode === id}
className={`border-2 px-3 py-1.5 text-sm font-medium transition-all ${mode === id ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal'}`}
>
{t.modes[id].label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">{t.modes[mode].hint}</p>
</div>

<fieldset className="flex flex-wrap gap-x-5 gap-y-2">
<legend className="mb-1 text-sm font-semibold">{t.optionsLabel}</legend>
{(Object.keys(t.opts) as (keyof LineSetOptions)[]).map((k) => (
<label key={k} className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={!!opts[k]} onChange={() => toggle(k)} className="h-4 w-4 accent-accent" />
{t.opts[k]}
</label>
))}
</fieldset>

<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-auto text-sm font-semibold">{t.result} Β· {t.resultCount(result.count)}</span>
<CopyButton value={resultText} />
<Button variant="secondary" onClick={download} disabled={result.count === 0}><Download className="h-4 w-4" /> {t.download}</Button>
</div>
<TextArea value={resultText} readOnly rows={10} spellCheck={false} placeholder={t.empty} />
</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> = {
'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.',
intro: 'Paste two lists and compare them line by line with set operations β€” merge and remove duplicates, subtract one list from the other, find the lines they share, spot what is unique to one side, or list duplicates. It runs entirely in your browser, so your data is never uploaded.',
howTo: [
'Paste your first list into List A and your second into List B (one item per line).',
'Pick an operation: Merge & dedupe, In A not B, In B not A, Common to both, In only one, or Duplicates.',
'Toggle options as needed β€” ignore case, trim whitespace, ignore blank lines, or sort the result.',
'Copy the result or download it as a .txt file.',
],
faqs: [
{ q: 'What can I do with two lists?', a: 'Merge them and remove duplicates (union), remove one list’s lines from the other (difference), keep only the lines they have in common (intersection), keep lines that are in just one list (symmetric difference), or list lines that appear more than once.' },
{ q: 'Is my data uploaded?', a: 'No. All comparison happens in your browser with JavaScript. Your lists never leave your device, so it is safe for private or sensitive data.' },
{ q: 'Can it ignore case and extra spaces?', a: 'Yes. Turn on β€œIgnore case” to match regardless of capitalisation and β€œTrim whitespace” to ignore leading and trailing spaces when comparing. You can also ignore blank lines and sort the output.' },
{ q: 'Are duplicates removed from the result?', a: 'Yes β€” every operation returns a de-duplicated list, preserving the first occurrence of each line and its original text.' },
],
},
'docx-to-pdf': {
title: 'Free DOCX to PDF Converter β€” Word to PDF Online',
description: 'Convert Word (.docx) documents to PDF right in your browser β€” page-accurate, with your layout, tables and images. 100% private; nothing is uploaded.',
Expand Down Expand Up @@ -1393,6 +1410,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'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.',
intro: 'Tempel dua daftar dan bandingkan baris per baris dengan operasi himpunan β€” gabung dan hapus duplikat, kurangi satu daftar dari yang lain, temukan baris yang sama, lihat yang hanya ada di salah satu sisi, atau daftar duplikat. Semuanya berjalan di browser Anda, jadi data Anda tidak pernah diunggah.',
howTo: [
'Tempel daftar pertama ke Daftar A dan daftar kedua ke Daftar B (satu item per baris).',
'Pilih operasi: Gabung & hapus duplikat, Di A tidak di B, Di B tidak di A, Sama di keduanya, Hanya di salah satu, atau Duplikat.',
'Aktifkan opsi sesuai kebutuhan β€” abaikan huruf besar/kecil, pangkas spasi, abaikan baris kosong, atau urutkan hasil.',
'Salin hasilnya atau unduh sebagai berkas .txt.',
],
faqs: [
{ q: 'Apa yang bisa saya lakukan dengan dua daftar?', a: 'Gabungkan dan hapus duplikat (union), hapus baris satu daftar dari yang lain (difference), simpan hanya baris yang sama (intersection), simpan baris yang hanya ada di satu daftar (symmetric difference), atau daftar baris yang muncul lebih dari sekali.' },
{ q: 'Apakah data saya diunggah?', a: 'Tidak. Semua perbandingan terjadi di browser Anda dengan JavaScript. Daftar Anda tidak pernah meninggalkan perangkat, jadi aman untuk data privat atau sensitif.' },
{ q: 'Bisakah mengabaikan huruf besar/kecil dan spasi berlebih?', a: 'Ya. Aktifkan β€œAbaikan huruf besar/kecil” untuk mencocokkan tanpa memandang kapitalisasi dan β€œPangkas spasi” untuk mengabaikan spasi di awal dan akhir saat membandingkan. Anda juga dapat mengabaikan baris kosong dan mengurutkan keluaran.' },
{ q: 'Apakah duplikat dihapus dari hasil?', a: 'Ya β€” setiap operasi mengembalikan daftar tanpa duplikat, mempertahankan kemunculan pertama setiap baris beserta teks aslinya.' },
],
},
'docx-to-pdf': {
title: 'Konverter DOCX ke PDF Gratis β€” Word ke PDF Online',
description: 'Konversi dokumen Word (.docx) ke PDF langsung di browser Anda β€” akurat per halaman, dengan tata letak, tabel, dan gambar. 100% privat; 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 } 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 } from 'lucide-react';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -157,6 +157,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/dev/GhostBackup'),
status: 'beta'
},
{
id: 'compare-lists',
name: 'Compare Two Lists',
category: 'Dev',
route: '/tools/compare-lists',
keywords: ['compare', 'lists', 'lines', 'diff', 'dedupe', 'duplicate', 'merge', 'union', 'intersection', 'difference', 'subtract', 'common', 'unique', 'set', 'text'],
icon: GitCompare,
summary: 'Merge, dedupe, subtract or find common lines between two lists',
load: () => import('@/islands/dev/CompareLists'),
status: 'beta'
},
{
id: 'docx-viewer',
name: 'Word (DOCX) Viewer',
Expand Down
82 changes: 82 additions & 0 deletions src/tools/dev/lineset.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { compareLines } from './lineset.lib';

const run = (a: string, b: string, mode: Parameters<typeof compareLines>[2], opts = {}) =>
compareLines(a, b, mode, opts).lines;

describe('compareLines β€” union (merge & dedupe)', () => {
it('combines both sources, A first, deduped', () => {
expect(run('a\nb\nc', 'b\nc\nd', 'union')).toEqual(['a', 'b', 'c', 'd']);
});
it('removes duplicates within a single source too', () => {
expect(run('a\na\nb', '', 'union')).toEqual(['a', 'b']);
});
});

describe('compareLines β€” difference (A βˆ’ B)', () => {
it('keeps A lines not present in B', () => {
expect(run('a\nb\nc', 'b', 'difference')).toEqual(['a', 'c']);
});
it('result is deduped and in A order', () => {
expect(run('a\nc\na\nd', 'd', 'difference')).toEqual(['a', 'c']);
});
});

describe('compareLines β€” intersection (B lines found in A)', () => {
it('returns B lines that appear in A, in B order, deduped', () => {
expect(run('a\nb\nc', 'c\nd\nc\nb', 'intersection')).toEqual(['c', 'b']);
});
});

describe('compareLines β€” differenceB (B βˆ’ A)', () => {
it('keeps B lines not present in A, in B order, deduped', () => {
expect(run('a\nb', 'b\nc\nd\nc', 'differenceB')).toEqual(['c', 'd']);
});
});

describe('compareLines β€” symmetric (in only one list)', () => {
it('returns lines unique to A then unique to B', () => {
expect(run('a\nb\nc', 'b\nc\nd', 'symmetric')).toEqual(['a', 'd']);
});
});

describe('compareLines β€” duplicates (2+ across A and B)', () => {
it('finds lines that appear more than once across both sources', () => {
expect(run('a\nb\nc', 'c\nd', 'duplicates')).toEqual(['c']);
});
it('counts repeats within a single source', () => {
expect(run('x\nx\ny', '', 'duplicates')).toEqual(['x']);
});
});

describe('options', () => {
it('caseInsensitive matches across case, keeping first original casing', () => {
expect(run('Apple', 'apple', 'difference', { caseInsensitive: true })).toEqual([]);
expect(run('Apple\nBanana', 'apple', 'union', { caseInsensitive: true })).toEqual(['Apple', 'Banana']);
});
it('trim ignores leading/trailing whitespace when comparing', () => {
expect(run(' a \nb', 'a', 'difference', { trim: true })).toEqual(['b']);
});
it('ignoreBlank drops empty lines', () => {
expect(run('a\n\n\nb', '', 'union', { ignoreBlank: true })).toEqual(['a', 'b']);
});
it('sort orders the output', () => {
expect(run('c\na\nb', 'd', 'union', { sort: true })).toEqual(['a', 'b', 'c', 'd']);
});
it('sort respects caseInsensitive ordering', () => {
expect(run('B\na\nC', '', 'union', { sort: true, caseInsensitive: true })).toEqual(['a', 'B', 'C']);
});
});

describe('edge cases', () => {
it('empty inputs yield empty output', () => {
expect(run('', '', 'union')).toEqual([]);
expect(compareLines('', '', 'union', {}).count).toBe(0);
});
it('reports the result count', () => {
expect(compareLines('a\nb\nc', 'c', 'difference', {}).count).toBe(2);
});
it('handles CRLF line endings', () => {
expect(run('a\r\nb\r\nc', 'b', 'difference')).toEqual(['a', 'c']);
});
});
Loading
Loading