diff --git a/components/utils/text-difference.utils.test.ts b/components/utils/text-difference.utils.test.ts new file mode 100644 index 0000000..259b6e4 --- /dev/null +++ b/components/utils/text-difference.utils.test.ts @@ -0,0 +1,139 @@ +import { calculateDiff, DiffResult } from "./text-difference.utils"; + +describe("text-difference.utils", () => { + it("should detect line additions, deletions, and unchanged lines", () => { + const oldText = "Line one\nLine to be removed\nLine three"; + const newText = "Line one\nLine three\nLine four"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Line one", type: "unchanged" }, + { text: "Line to be removed", type: "removed" }, + { text: "Line three", type: "unchanged" }, + { text: "Line four", type: "added" }, + ]; + expect(result).toEqual(expected); + }); + + it("should detect completely new lines", () => { + const oldText = ""; + const newText = "New line one\nNew line two"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "New line one", type: "added" }, + { text: "New line two", type: "added" }, + ]; + expect(result).toEqual(expected); + }); + + it("should detect completely removed lines", () => { + const oldText = "Old line one\nOld line two"; + const newText = ""; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Old line one", type: "removed" }, + { text: "Old line two", type: "removed" }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle identical texts", () => { + const oldText = "Same line one\nSame line two"; + const newText = "Same line one\nSame line two"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Same line one", type: "unchanged" }, + { text: "Same line two", type: "unchanged" }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle mixed changes", () => { + const oldText = "Line one\nLine two\nLine three\nLine four"; + const newText = "Line one\nLine 2\nLine three\nLine four\nLine five"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Line one", type: "unchanged" }, + { text: "Line two", type: "removed" }, + { text: "Line 2", type: "added" }, + { text: "Line three", type: "unchanged" }, + { text: "Line four", type: "unchanged" }, + { text: "Line five", type: "added" }, + ]; + expect(result).toEqual(expected); + }); + + it("should detect line changes as removals and additions", () => { + const oldText = "Hello world"; + const newText = "Hello brave new world"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Hello world", type: "removed" }, + { text: "Hello brave new world", type: "added" }, + ]; + expect(result).toEqual(expected); + }); + + it("should ignore the last empty line if present", () => { + const oldText = "Line one\nLine two\n"; + const newText = "Line one\nLine two\nLine three\n"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Line one", type: "unchanged" }, + { text: "Line two", type: "unchanged" }, + { text: "Line three", type: "added" }, + ]; + expect(result).toEqual(expected); + }); + + it("should throw an error when inputs are not strings", () => { + // @ts-expect-error Testing with invalid inputs + expect(() => calculateDiff(null, "Valid string")).toThrow( + "Failed to calculate text differences." + ); + + // @ts-expect-error Testing with invalid inputs + expect(() => calculateDiff("Valid string", undefined)).toThrow( + "Failed to calculate text differences." + ); + }); + + it("should handle inputs with only whitespace", () => { + const oldText = " \n\t\n"; + const newText = " \n\t\n"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: " ", type: "unchanged" }, + { text: "\t", type: "unchanged" }, + ]; + expect(result).toEqual(expected); + }); + + it("should handle large texts efficiently", () => { + const oldText = Array.from( + { length: 1000 }, + (_, i) => `Line ${i + 1}` + ).join("\n"); + const newText = Array.from( + { length: 1000 }, + (_, i) => `Line ${i + 1}` + ).join("\n"); + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = Array.from({ length: 1000 }, (_, i) => ({ + text: `Line ${i + 1}`, + type: "unchanged" as const, + })); + expect(result).toEqual(expected); + }); + + it("should handle texts without trailing newline correctly", () => { + const oldText = "Line one\nLine two"; + const newText = "Line one\nLine two\nLine three"; + const result = calculateDiff(oldText, newText); + const expected: DiffResult[] = [ + { text: "Line one", type: "unchanged" }, + { text: "Line two", type: "unchanged" }, + { text: "Line three", type: "added" }, + ]; + expect(result).toEqual(expected); + }); +}); diff --git a/components/utils/text-difference.utils.ts b/components/utils/text-difference.utils.ts new file mode 100644 index 0000000..1828177 --- /dev/null +++ b/components/utils/text-difference.utils.ts @@ -0,0 +1,38 @@ +import { diffLines, Change } from "diff"; + +export type DiffResult = { + text: string; + type: "added" | "removed" | "unchanged"; +}; + +export function calculateDiff(oldText: string, newText: string): DiffResult[] { + try { + const normalizedOldText = + oldText === "" ? "" : oldText.endsWith("\n") ? oldText : oldText + "\n"; + const normalizedNewText = + newText === "" ? "" : newText.endsWith("\n") ? newText : newText + "\n"; + + const diff: Change[] = diffLines(normalizedOldText, normalizedNewText); + + return diff + .map((part) => { + const lineType: DiffResult["type"] = part.added + ? "added" + : part.removed + ? "removed" + : "unchanged"; + const lines = part.value.split("\n").map((line) => ({ + text: line, + type: lineType, + })); + // Remove the last empty line if present + if (lines[lines.length - 1]?.text === "") { + lines.pop(); + } + return lines; + }) + .flat(); + } catch (error) { + throw new Error("Failed to calculate text differences."); + } +} diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index 0e5db45..5d6533c 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -107,6 +107,12 @@ export const tools = [ "Resize images while maintaining aspect ratio and choose between PNG and JPEG formats with our free tool.", link: "/utilities/image-resizer", }, + { + title: "Text Difference Checker", + description: + "Compare two text files or strings and quickly identify differences between them.", + link: "/utilities/text-difference", + }, { title: "JWT Parser", description: diff --git a/package-lock.json b/package-lock.json index 41b631d..820d7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "curlconverter": "^4.10.1", + "diff": "^7.0.0", "js-yaml": "^4.1.0", "lucide-react": "^0.414.0", "next": "14.2.4", @@ -33,6 +34,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", + "@types/diff": "^5.2.2", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", "@types/node": "^20", @@ -2975,6 +2977,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/diff": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", + "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", + "dev": true + }, "node_modules/@types/eslint": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", @@ -5165,11 +5173,9 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "optional": true, - "peer": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "engines": { "node": ">=0.3.1" } @@ -11565,6 +11571,16 @@ "optional": true, "peer": true }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/package.json b/package.json index a7759ea..0969f46 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "curlconverter": "^4.10.1", + "diff": "^7.0.0", "js-yaml": "^4.1.0", "lucide-react": "^0.414.0", "next": "14.2.4", @@ -37,6 +38,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", + "@types/diff": "^5.2.2", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", "@types/node": "^20", diff --git a/pages/utilities/text-difference.tsx b/pages/utilities/text-difference.tsx new file mode 100644 index 0000000..7f53e41 --- /dev/null +++ b/pages/utilities/text-difference.tsx @@ -0,0 +1,110 @@ +import { useMemo, useState } from "react"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import GitHubContribution from "@/components/GitHubContribution"; +import { calculateDiff } from "@/components/utils/text-difference.utils"; + +export default function TextDifference() { + const [input1, setInput1] = useState(""); + const [input2, setInput2] = useState(""); + const [error, setError] = useState(null); + + const diffResults = useMemo(() => { + if (input1.trim() === "" && input2.trim() === "") { + return []; + } + try { + const results = calculateDiff(input1, input2); + setError(null); + return results; + } catch (err) { + setError((err as Error).message || "An unexpected error occurred."); + return []; + } + }, [input1, input2]); + + return ( +
+ +
+ + +
+ +
+ +
+ +
+ +