@@ -81,4 +119,24 @@ const { t } = useI18n();
opacity: 0;
margin-bottom: 0;
}
+
+.ghost-favorites-draggable {
+ opacity: 0.4;
+ background-color: #ccc;
+ border: 2px dashed #666;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+ transform: scale(1.1);
+ animation: ghost-favorites-draggable-animation 0.2s ease-out;
+}
+
+@keyframes ghost-favorites-draggable-animation {
+ 0% {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+ 100% {
+ opacity: 0.4;
+ transform: scale(1.0);
+ }
+}
diff --git a/src/tools/email-normalizer/email-normalizer.vue b/src/tools/email-normalizer/email-normalizer.vue
new file mode 100644
index 000000000..eae97c4ee
--- /dev/null
+++ b/src/tools/email-normalizer/email-normalizer.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Raw emails to normalize:
+
+
+
+
+ Normalized emails:
+
+
+
+
+ Clear emails
+
+
+ Copy normalized emails
+
+
+
+
diff --git a/src/tools/email-normalizer/index.ts b/src/tools/email-normalizer/index.ts
new file mode 100644
index 000000000..299a30f73
--- /dev/null
+++ b/src/tools/email-normalizer/index.ts
@@ -0,0 +1,12 @@
+import { Mail } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Email normalizer',
+ path: '/email-normalizer',
+ description: 'Normalize email addresses to a standard format for easier comparison. Useful for deduplication and data cleaning.',
+ keywords: ['email', 'normalizer'],
+ component: () => import('./email-normalizer.vue'),
+ icon: Mail,
+ createdAt: new Date('2024-08-15'),
+});
diff --git a/src/tools/html-wysiwyg-editor/editor/menu-bar.vue b/src/tools/html-wysiwyg-editor/editor/menu-bar.vue
index d3ad31686..9069673c6 100644
--- a/src/tools/html-wysiwyg-editor/editor/menu-bar.vue
+++ b/src/tools/html-wysiwyg-editor/editor/menu-bar.vue
@@ -84,8 +84,8 @@ const items: MenuItem[] = [
type: 'button',
icon: H3,
title: 'Heading 3',
- action: () => editor.value.chain().focus().toggleHeading({ level: 4 }).run(),
- isActive: () => editor.value.isActive('heading', { level: 4 }),
+ action: () => editor.value.chain().focus().toggleHeading({ level: 3 }).run(),
+ isActive: () => editor.value.isActive('heading', { level: 3 }),
},
{
type: 'button',
diff --git a/src/tools/index.ts b/src/tools/index.ts
index da1fe1454..1ce9eb805 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -2,11 +2,17 @@ import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as markdownTocGenerator } from './markdown-toc-generator';
+import { tool as emailNormalizer } from './email-normalizer';
import { tool as asciiTextDrawer } from './ascii-text-drawer';
import { tool as textToUnicode } from './text-to-unicode';
import { tool as safelinkDecoder } from './safelink-decoder';
+import { tool as xmlToJson } from './xml-to-json';
+import { tool as jsonToXml } from './json-to-xml';
+import { tool as regexTester } from './regex-tester';
+import { tool as regexMemo } from './regex-memo';
+import { tool as markdownToHtml } from './markdown-to-html';
import { tool as pdfSignatureChecker } from './pdf-signature-checker';
import { tool as numeronymGenerator } from './numeronym-generator';
import { tool as macAddressGenerator } from './mac-address-generator';
@@ -109,6 +115,9 @@ export const toolsByCategory: ToolCategory[] = [
tomlToJson,
tomlToYaml,
markdownTocGenerator,
+ xmlToJson,
+ jsonToXml,
+ markdownToHtml,
],
},
{
@@ -150,6 +159,9 @@ export const toolsByCategory: ToolCategory[] = [
dockerRunToDockerComposeConverter,
xmlFormatter,
yamlViewer,
+ emailNormalizer,
+ regexTester,
+ regexMemo,
],
},
{
diff --git a/src/tools/json-to-xml/index.ts b/src/tools/json-to-xml/index.ts
new file mode 100644
index 000000000..c35ace2bb
--- /dev/null
+++ b/src/tools/json-to-xml/index.ts
@@ -0,0 +1,12 @@
+import { Braces } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'JSON to XML',
+ path: '/json-to-xml',
+ description: 'Convert JSON to XML',
+ keywords: ['json', 'xml'],
+ component: () => import('./json-to-xml.vue'),
+ icon: Braces,
+ createdAt: new Date('2024-08-09'),
+});
diff --git a/src/tools/json-to-xml/json-to-xml.vue b/src/tools/json-to-xml/json-to-xml.vue
new file mode 100644
index 000000000..96a7cf162
--- /dev/null
+++ b/src/tools/json-to-xml/json-to-xml.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue b/src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue
index 9085725f9..ccd8b519a 100644
--- a/src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue
+++ b/src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue
@@ -2,6 +2,7 @@
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
import { useCopy } from '@/composable/copy';
import { randIntFromInterval } from '@/utils/random';
+import { computedRefreshable } from '@/composable/computedRefreshable';
const paragraphs = ref(1);
const sentences = ref([3, 8]);
@@ -9,7 +10,7 @@ const words = ref([8, 15]);
const startWithLoremIpsum = ref(true);
const asHTML = ref(false);
-const loremIpsumText = computed(() =>
+const [loremIpsumText, refreshLoremIpsum] = computedRefreshable(() =>
generateLoremIpsum({
paragraphCount: paragraphs.value,
asHTML: asHTML.value,
@@ -18,6 +19,7 @@ const loremIpsumText = computed(() =>
startWithLoremIpsum: startWithLoremIpsum.value,
}),
);
+
const { copy } = useCopy({ source: loremIpsumText, text: 'Lorem ipsum copied to the clipboard' });
@@ -41,10 +43,13 @@ const { copy } = useCopy({ source: loremIpsumText, text: 'Lorem ipsum copied to
-
+
Copy
+
+ Refresh
+
diff --git a/src/tools/markdown-to-html/index.ts b/src/tools/markdown-to-html/index.ts
new file mode 100644
index 000000000..73a6cfb33
--- /dev/null
+++ b/src/tools/markdown-to-html/index.ts
@@ -0,0 +1,12 @@
+import { Markdown } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Markdown to HTML',
+ path: '/markdown-to-html',
+ description: 'Convert Markdown to Html and allow to print (as PDF)',
+ keywords: ['markdown', 'html', 'converter', 'pdf'],
+ component: () => import('./markdown-to-html.vue'),
+ icon: Markdown,
+ createdAt: new Date('2024-08-25'),
+});
diff --git a/src/tools/markdown-to-html/markdown-to-html.vue b/src/tools/markdown-to-html/markdown-to-html.vue
new file mode 100644
index 000000000..c84d44ecf
--- /dev/null
+++ b/src/tools/markdown-to-html/markdown-to-html.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Print as PDF
+
+
+
+
diff --git a/src/tools/regex-memo/index.ts b/src/tools/regex-memo/index.ts
new file mode 100644
index 000000000..f1f56489d
--- /dev/null
+++ b/src/tools/regex-memo/index.ts
@@ -0,0 +1,12 @@
+import { BrandJavascript } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Regex cheatsheet',
+ path: '/regex-memo',
+ description: 'Javascript Regex/Regular Expression cheatsheet',
+ keywords: ['regex', 'regular', 'expression', 'javascript', 'memo', 'cheatsheet'],
+ component: () => import('./regex-memo.vue'),
+ icon: BrandJavascript,
+ createdAt: new Date('2024-09-20'),
+});
diff --git a/src/tools/regex-memo/regex-memo.content.md b/src/tools/regex-memo/regex-memo.content.md
new file mode 100644
index 000000000..0f779401b
--- /dev/null
+++ b/src/tools/regex-memo/regex-memo.content.md
@@ -0,0 +1,121 @@
+### Normal characters
+
+Expression | Description
+:--|:--
+`.` or `[^\n\r]` | any character *excluding* a newline or carriage return
+`[A-Za-z]` | alphabet
+`[a-z]` | lowercase alphabet
+`[A-Z]` | uppercase alphabet
+`\d` or `[0-9]` | digit
+`\D` or `[^0-9]` | non-digit
+`_` | underscore
+`\w` or `[A-Za-z0-9_]` | alphabet, digit or underscore
+`\W` or `[^A-Za-z0-9_]` | inverse of `\w`
+`\S` | inverse of `\s`
+
+### Whitespace characters
+
+Expression | Description
+:--|:--
+` ` | space
+`\t` | tab
+`\n` | newline
+`\r` | carriage return
+`\s` | space, tab, newline or carriage return
+
+### Character set
+
+Expression | Description
+:--|:--
+`[xyz]` | either `x`, `y` or `z`
+`[^xyz]` | neither `x`, `y` nor `z`
+`[1-3]` | either `1`, `2` or `3`
+`[^1-3]` | neither `1`, `2` nor `3`
+
+- Think of a character set as an `OR` operation on the single characters that are enclosed between the square brackets.
+- Use `^` after the opening `[` to “negate” the character set.
+- Within a character set, `.` means a literal period.
+
+### Characters that require escaping
+
+#### Outside a character set
+
+Expression | Description
+:--|:--
+`\.` | period
+`\^` | caret
+`\$` | dollar sign
+`\|` | pipe
+`\\` | back slash
+`\/` | forward slash
+`\(` | opening bracket
+`\)` | closing bracket
+`\[` | opening square bracket
+`\]` | closing square bracket
+`\{` | opening curly bracket
+`\}` | closing curly bracket
+
+#### Inside a character set
+
+Expression | Description
+:--|:--
+`\\` | back slash
+`\]` | closing square bracket
+
+- A `^` must be escaped only if it occurs immediately after the opening `[` of the character set.
+- A `-` must be escaped only if it occurs between two alphabets or two digits.
+
+### Quantifiers
+
+Expression | Description
+:--|:--
+`{2}` | exactly 2
+`{2,}` | at least 2
+`{2,7}` | at least 2 but no more than 7
+`*` | 0 or more
+`+` | 1 or more
+`?` | exactly 0 or 1
+
+- The quantifier goes *after* the expression to be quantified.
+
+### Boundaries
+
+Expression | Description
+:--|:--
+`^` | start of string
+`$` | end of string
+`\b` | word boundary
+
+- How word boundary matching works:
+ - At the beginning of the string if the first character is `\w`.
+ - Between two adjacent characters within the string, if the first character is `\w` and the second character is `\W`.
+ - At the end of the string if the last character is `\w`.
+
+### Matching
+
+Expression | Description
+:--|:--
+`foo\|bar` | match either `foo` or `bar`
+`foo(?=bar)` | match `foo` if it’s before `bar`
+`foo(?!bar)` | match `foo` if it’s *not* before `bar`
+`(?<=bar)foo` | match `foo` if it’s after `bar`
+`(?
+import { useThemeVars } from 'naive-ui';
+import Memo from './regex-memo.content.md';
+
+const themeVars = useThemeVars();
+
+
+
+
+
+
+
+
+
diff --git a/src/tools/regex-tester/index.ts b/src/tools/regex-tester/index.ts
new file mode 100644
index 000000000..62a5e2346
--- /dev/null
+++ b/src/tools/regex-tester/index.ts
@@ -0,0 +1,12 @@
+import { Language } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Regex Tester',
+ path: '/regex-tester',
+ description: 'Test your regular expressions with sample text.',
+ keywords: ['regex', 'tester', 'sample', 'expression'],
+ component: () => import('./regex-tester.vue'),
+ icon: Language,
+ createdAt: new Date('2024-09-20'),
+});
diff --git a/src/tools/regex-tester/regex-tester.service.test.ts b/src/tools/regex-tester/regex-tester.service.test.ts
new file mode 100644
index 000000000..bd4efbbc8
--- /dev/null
+++ b/src/tools/regex-tester/regex-tester.service.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+import { matchRegex } from './regex-tester.service';
+
+const regexesData = [
+ {
+ regex: '',
+ text: '',
+ flags: '',
+ result: [],
+ },
+ {
+ regex: '.*',
+ text: '',
+ flags: '',
+ result: [],
+ },
+ {
+ regex: '',
+ text: 'aaa',
+ flags: '',
+ result: [],
+ },
+ {
+ regex: 'a',
+ text: 'baaa',
+ flags: '',
+ result: [
+ {
+ captures: [],
+ groups: [],
+ index: 1,
+ value: 'a',
+ },
+ ],
+ },
+ {
+ regex: '(.)(?
r)',
+ text: 'azertyr',
+ flags: 'g',
+ result: [
+ {
+ captures: [
+ {
+ end: 3,
+ name: '1',
+ start: 2,
+ value: 'e',
+ },
+ {
+ end: 4,
+ name: '2',
+ start: 3,
+ value: 'r',
+ },
+ ],
+ groups: [
+ {
+ end: 4,
+ name: 'g',
+ start: 3,
+ value: 'r',
+ },
+ ],
+ index: 2,
+ value: 'er',
+ },
+ {
+ captures: [
+ {
+ end: 6,
+ name: '1',
+ start: 5,
+ value: 'y',
+ },
+ {
+ end: 7,
+ name: '2',
+ start: 6,
+ value: 'r',
+ },
+ ],
+ groups: [
+ {
+ end: 7,
+ name: 'g',
+ start: 6,
+ value: 'r',
+ },
+ ],
+ index: 5,
+ value: 'yr',
+ },
+ ],
+ },
+];
+
+describe('regex-tester', () => {
+ for (const reg of regexesData) {
+ const { regex, text, flags, result: expected_result } = reg;
+ it(`Should matchRegex("${regex}","${text}","${flags}") return correct result`, async () => {
+ const result = matchRegex(regex, text, `${flags}d`);
+
+ expect(result).to.deep.equal(expected_result);
+ });
+ }
+});
diff --git a/src/tools/regex-tester/regex-tester.service.ts b/src/tools/regex-tester/regex-tester.service.ts
new file mode 100644
index 000000000..ec8682c5c
--- /dev/null
+++ b/src/tools/regex-tester/regex-tester.service.ts
@@ -0,0 +1,61 @@
+interface RegExpGroupIndices {
+ [name: string]: [number, number]
+}
+interface RegExpIndices extends Array<[number, number]> {
+ groups: RegExpGroupIndices
+}
+interface RegExpExecArrayWithIndices extends RegExpExecArray {
+ indices: RegExpIndices
+}
+interface GroupCapture {
+ name: string
+ value: string
+ start: number
+ end: number
+};
+
+export function matchRegex(regex: string, text: string, flags: string) {
+ // if (regex === '' || text === '') {
+ // return [];
+ // }
+
+ let lastIndex = -1;
+ const re = new RegExp(regex, flags);
+ const results = [];
+ let match = re.exec(text) as RegExpExecArrayWithIndices;
+ while (match !== null) {
+ if (re.lastIndex === lastIndex || match[0] === '') {
+ break;
+ }
+ const indices = match.indices;
+ const captures: Array = [];
+ Object.entries(match).forEach(([captureName, captureValue]) => {
+ if (captureName !== '0' && captureName.match(/\d+/)) {
+ captures.push({
+ name: captureName,
+ value: captureValue,
+ start: indices[Number(captureName)][0],
+ end: indices[Number(captureName)][1],
+ });
+ }
+ });
+ const groups: Array = [];
+ Object.entries(match.groups || {}).forEach(([groupName, groupValue]) => {
+ groups.push({
+ name: groupName,
+ value: groupValue,
+ start: indices.groups[groupName][0],
+ end: indices.groups[groupName][1],
+ });
+ });
+ results.push({
+ index: match.index,
+ value: match[0],
+ captures,
+ groups,
+ });
+ lastIndex = re.lastIndex;
+ match = re.exec(text) as RegExpExecArrayWithIndices;
+ }
+ return results;
+}
diff --git a/src/tools/regex-tester/regex-tester.vue b/src/tools/regex-tester/regex-tester.vue
new file mode 100644
index 000000000..a1fa79584
--- /dev/null
+++ b/src/tools/regex-tester/regex-tester.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+ See Regular Expression Cheatsheet
+
+
+
+ Global search. (g
)
+
+
+ Case-insensitive search. (i
)
+
+
+ Multiline(m
)
+
+
+ Singleline(s
)
+
+
+ Unicode(u
)
+
+
+ Unicode Sets (v
)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Index in text
+ |
+
+ Value
+ |
+
+ Captures
+ |
+
+ Groups
+ |
+
+
+
+
+ {{ match.index }} |
+ {{ match.value }} |
+
+
+ -
+ "{{ capture.name }}" = {{ capture.value }} [{{ capture.start }} - {{ capture.end }}]
+
+
+ |
+
+
+ -
+ "{{ group.name }}" = {{ group.value }} [{{ group.start }} - {{ group.end }}]
+
+
+ |
+
+
+
+
+ No match
+
+
+
+
+ {{ sample }}
+
+
+
+
+
+
+
+
+
diff --git a/src/tools/tools.store.ts b/src/tools/tools.store.ts
index d952b7cbc..fb12450df 100644
--- a/src/tools/tools.store.ts
+++ b/src/tools/tools.store.ts
@@ -14,6 +14,7 @@ export const useToolStore = defineStore('tools', () => {
return ({
...tool,
+ path: tool.path,
name: t(`tools.${toolI18nKey}.title`, tool.name),
description: t(`tools.${toolI18nKey}.description`, tool.description),
category: t(`tools.categories.${tool.category.toLowerCase()}`, tool.category),
@@ -23,8 +24,9 @@ export const useToolStore = defineStore('tools', () => {
const toolsByCategory = computed(() => {
return _.chain(tools.value)
.groupBy('category')
- .map((components, name) => ({
+ .map((components, name, path) => ({
name,
+ path,
components,
}))
.value();
@@ -32,7 +34,7 @@ export const useToolStore = defineStore('tools', () => {
const favoriteTools = computed(() => {
return favoriteToolsName.value
- .map(favoriteName => tools.value.find(({ name }) => name === favoriteName))
+ .map(favoriteName => tools.value.find(({ name, path }) => name === favoriteName || path === favoriteName))
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
});
@@ -43,15 +45,23 @@ export const useToolStore = defineStore('tools', () => {
newTools: computed(() => tools.value.filter(({ isNew }) => isNew)),
addToolToFavorites({ tool }: { tool: MaybeRef }) {
- favoriteToolsName.value.push(get(tool).name);
+ const toolPath = get(tool).path;
+ if (toolPath) {
+ favoriteToolsName.value.push(toolPath);
+ }
},
removeToolFromFavorites({ tool }: { tool: MaybeRef }) {
- favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name);
+ favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name && get(tool).path !== name);
},
isToolFavorite({ tool }: { tool: MaybeRef }) {
- return favoriteToolsName.value.includes(get(tool).name);
+ return favoriteToolsName.value.includes(get(tool).name)
+ || favoriteToolsName.value.includes(get(tool).path);
+ },
+
+ updateFavoriteTools(newOrder: ToolWithCategory[]) {
+ favoriteToolsName.value = newOrder.map(tool => tool.path);
},
};
});
diff --git a/src/tools/xml-to-json/index.ts b/src/tools/xml-to-json/index.ts
new file mode 100644
index 000000000..8d83f4fe6
--- /dev/null
+++ b/src/tools/xml-to-json/index.ts
@@ -0,0 +1,12 @@
+import { Braces } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'XML to JSON',
+ path: '/xml-to-json',
+ description: 'Convert XML to JSON',
+ keywords: ['xml', 'json'],
+ component: () => import('./xml-to-json.vue'),
+ icon: Braces,
+ createdAt: new Date('2024-08-09'),
+});
diff --git a/src/tools/xml-to-json/xml-to-json.vue b/src/tools/xml-to-json/xml-to-json.vue
new file mode 100644
index 000000000..e1e5a4771
--- /dev/null
+++ b/src/tools/xml-to-json/xml-to-json.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts b/src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts
index 7b2a2d182..d6ed84c3d 100644
--- a/src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts
+++ b/src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts
@@ -28,4 +28,53 @@ test.describe('Tool - Yaml to json', () => {
`.trim(),
);
});
+
+ test('Yaml is parsed with merge key and output correct json', async ({ page }) => {
+ await page.getByTestId('input').fill(`
+ default: &default
+ name: ''
+ age: 0
+
+ person:
+ *default
+
+ persons:
+ - <<: *default
+ age: 1
+ - <<: *default
+ name: John
+ - { age: 3, <<: *default }
+
+ `);
+
+ const generatedJson = await page.getByTestId('area-content').innerText();
+
+ expect(generatedJson.trim()).toEqual(
+ `
+{
+ "default": {
+ "name": "",
+ "age": 0
+ },
+ "person": {
+ "name": "",
+ "age": 0
+ },
+ "persons": [
+ {
+ "name": "",
+ "age": 1
+ },
+ {
+ "name": "John",
+ "age": 0
+ },
+ {
+ "age": 3,
+ "name": ""
+ }
+ ]
+}`.trim(),
+ );
+ });
});
diff --git a/src/tools/yaml-to-json-converter/yaml-to-json.vue b/src/tools/yaml-to-json-converter/yaml-to-json.vue
index 39c9297f2..72608add1 100644
--- a/src/tools/yaml-to-json-converter/yaml-to-json.vue
+++ b/src/tools/yaml-to-json-converter/yaml-to-json.vue
@@ -6,7 +6,7 @@ import { withDefaultOnError } from '@/utils/defaults';
function transformer(value: string) {
return withDefaultOnError(() => {
- const obj = parseYaml(value);
+ const obj = parseYaml(value, { merge: true });
return obj ? JSON.stringify(obj, null, 3) : '';
}, '');
}