Compare commits

...

34 Commits

Author SHA1 Message Date
Corentin Thomasset
cf5e4d9056 chore(release): 2.10.3 2022-08-14 10:55:06 +02:00
Corentin Thomasset
992f96b48a refactor(share): updated twitter meta tags 2022-08-14 10:53:45 +02:00
Corentin Thomasset
fcf4cfe64d refactor(share): new share banner 2022-08-14 10:53:39 +02:00
Corentin Thomasset
f54223fb0a refactor(validation): simplified validation management with helpers 2022-08-04 21:59:48 +02:00
Corentin Thomasset
b38ab82d05 chore(release): 2.10.2 2022-08-04 23:29:47 +02:00
Corentin Thomasset
f6cd9b76d3 refactor(dry): mutualised duplicated code with withDefaultOnError 2022-08-04 23:14:32 +02:00
Corentin Thomasset
208a373fd0 refactor(lint): added import rules 2022-08-04 22:46:50 +02:00
Corentin Thomasset
8089c60000 refactor(json-prettifier): more permissive json parser 2022-08-04 22:18:15 +02:00
Corentin Thomasset
d30cd8a9ab refactor(home): removed new tool first sort 2022-08-04 21:59:22 +02:00
Corentin Thomasset
04a8e122be chore(release): 2.10.1 2022-08-04 12:16:54 +02:00
Corentin Thomasset
447bdf2148 refactor(base64): mutualized base64 functions into global utilities 2022-08-04 12:09:32 +02:00
Corentin Thomasset
ca7cb44389 fix(bip39-generator): cleared an issue with the mnemonic validation 2022-08-04 12:08:23 +02:00
Corentin Thomasset
e48d60b1ed refactor(chronometer): improved chronometer precision 2022-08-04 09:06:42 +02:00
Corentin Thomasset
fda0b0ca25 fix(import): removed auto added weird .js extension 2022-08-04 08:50:15 +02:00
Corentin Thomasset
cc717bc87e chore(release): 2.10.0 2022-08-03 17:25:48 +02:00
Corentin Thomasset
1bc6380c6f feat(new-tool): hmac generator 2022-08-03 17:19:53 +02:00
Corentin Thomasset
02c4963531 refactor(lint): externalization of prettier for simpler IDE support 2022-08-03 17:19:53 +02:00
Corentin Thomasset
129f74c371 feat(new-tool): an svg placeholder image generator 2022-08-03 17:18:18 +02:00
Corentin Thomasset
0be33fb337 refactor(display): mutualized code display 2022-08-03 17:18:18 +02:00
Corentin THOMASSET
422b6eb05a feat(hash-text): digest base selector (#254) 2022-08-03 17:16:03 +02:00
Corentin Thomasset
fad4833ca2 chore(deps): updated dependencies 2022-08-03 17:13:34 +02:00
marvin-j97
531a25c1c4 fix(eta-calculator): clamp inputs (#249) 2022-08-03 17:13:33 +02:00
Corentin Thomasset
77b5b0cab5 refactor(validation): simplified validation system 2022-08-03 17:13:33 +02:00
Corentin Thomasset
7570ad9656 fix(bip39-generator): typo in validation message 2022-08-03 17:13:33 +02:00
Corentin Thomasset
8a9e7888de fix(base64-to-string): prevent validation error 2022-08-03 17:13:33 +02:00
Corentin Thomasset
750a76b00f refactor(base64-to-file): clean validation to convert base64 to file 2022-08-03 17:13:33 +02:00
marvin-j97
5f03619ab4 fix(wording): removed spaces before ponctuation (#252) 2022-08-03 08:46:59 +02:00
Corentin Thomasset
352365f012 chore(release): 2.9.2 2022-07-28 19:31:16 +02:00
Corentin Thomasset
4f599b6999 fix(device-information): handle of unknown values 2022-07-28 19:30:36 +02:00
JWB
138149e6f0 fix(device-information): prevent unwanted y-truncature of text
* Device information page styling fix

I was checking out your site, and really like it! already added to my bookmarks, and when I was on the Device Information page I noticed the `p` and `y` in the words 1 dppx and landscape-primary were cut off and my ocd kicked in to tweak it.
The fix was to remove the line height set on the value, and then to keep the spacing how it was just moving it up 5px seemed to do the trick.

I'm using Firefox 102.0.1 on Windows 11. I'll attach some pics so you can see the before and after.

I've never messed with vue, but after looking at this file here ya make me want to check it out more. I love how its keeping it all together in one file.
Might also need to rebuild it the site after this change, but you get the point. 

Awesome site!

* Update src/tools/device-information/device-information.vue

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2022-07-28 19:28:23 +02:00
Corentin Thomasset
412de23796 fix(base64-file): fixed url slug 2022-07-25 23:31:06 +02:00
Corentin Thomasset
1a22d55b3c refactor(base64-file): fixed typo 2022-07-25 23:23:53 +02:00
Corentin Thomasset
bb4aac6d4a chore(release): 2.9.1 2022-07-25 23:22:32 +02:00
Corentin Thomasset
e6953d1b67 refactor(base64): split base64 text and file conversion in two tools + base64 to file 2022-07-25 23:21:42 +02:00
54 changed files with 5125 additions and 3435 deletions

View File

@@ -7,23 +7,28 @@ module.exports = {
'plugin:vue/vue3-essential', 'plugin:vue/vue3-essential',
'eslint:recommended', 'eslint:recommended',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript/recommended', '@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier', '@vue/eslint-config-prettier',
'plugin:import/recommended',
], ],
settings: {
'import/resolver': { typescript: { project: './tsconfig.app.json' } },
},
env: { env: {
'vue/setup-compiler-macros': true, 'vue/setup-compiler-macros': true,
}, },
rules: { rules: {
'vue/multi-word-component-names': ['off'], 'vue/multi-word-component-names': ['off'],
'prettier/prettier': [ 'prettier/prettier': ['error'],
'import/no-duplicates': ['error', { considerQueryString: true }],
'import/order': ['error', { groups: [['builtin', 'external', 'internal']] }],
'import/extensions': [
'error', 'error',
'ignorePackages',
{ {
singleQuote: true, js: 'never',
semi: true, ts: 'never',
tabWidth: 2,
trailingComma: 'all',
printWidth: 120,
}, },
], ],
}, },

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 120
}

View File

@@ -2,6 +2,85 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [2.10.3](https://github.com/CorentinTh/it-tools/compare/v2.10.2...v2.10.3) (2022-08-14)
### Refactors
* **share:** new share banner ([fcf4cfe](https://github.com/CorentinTh/it-tools/commit/fcf4cfe64d4c1c3814137c8ff23b83a1ca0d502d))
* **share:** updated twitter meta tags ([992f96b](https://github.com/CorentinTh/it-tools/commit/992f96b48a89e2793ccf75fb9e28b2ec7b7f62b6))
* **validation:** simplified validation management with helpers ([f54223f](https://github.com/CorentinTh/it-tools/commit/f54223fb0aaedbd101b5d3dc4176053533bb936a))
### [2.10.2](https://github.com/CorentinTh/it-tools/compare/v2.10.1...v2.10.2) (2022-08-04)
### Refactors
* **dry:** mutualised duplicated code with withDefaultOnError ([f6cd9b7](https://github.com/CorentinTh/it-tools/commit/f6cd9b76d38800e1a1f63d07152fc96cda562795))
* **home:** removed new tool first sort ([d30cd8a](https://github.com/CorentinTh/it-tools/commit/d30cd8a9abc3298c0a0b05f249e54318bb4537f2))
* **json-prettifier:** more permissive json parser ([8089c60](https://github.com/CorentinTh/it-tools/commit/8089c60000000c42c821c6586c128d3d2b248885))
* **lint:** added import rules ([208a373](https://github.com/CorentinTh/it-tools/commit/208a373fd08ac550778745eb6e4536bf02537da7))
### [2.10.1](https://github.com/CorentinTh/it-tools/compare/v2.10.0...v2.10.1) (2022-08-04)
### Bug Fixes
* **bip39-generator:** cleared an issue with the mnemonic validation ([ca7cb44](https://github.com/CorentinTh/it-tools/commit/ca7cb4438972ca09f28a6a40332ec94ceaa4aab4))
* **import:** removed auto added weird .js extension ([fda0b0c](https://github.com/CorentinTh/it-tools/commit/fda0b0ca25c1733542a4e797ac1a2150c546a660))
### Refactors
* **base64:** mutualized base64 functions into global utilities ([447bdf2](https://github.com/CorentinTh/it-tools/commit/447bdf2148098d70ba309e13d9b1e846b5064da1))
* **chronometer:** improved chronometer precision ([e48d60b](https://github.com/CorentinTh/it-tools/commit/e48d60b1ed19279f48441743f7ed69e8fd915011))
## [2.10.0](https://github.com/CorentinTh/it-tools/compare/v2.9.2...v2.10.0) (2022-08-03)
### Features
* **hash-text:** digest base selector ([#254](https://github.com/CorentinTh/it-tools/issues/254)) ([422b6eb](https://github.com/CorentinTh/it-tools/commit/422b6eb05a2fb5e7eec816a6bd2d37b53e4a6bdc))
* **new-tool:** an svg placeholder image generator ([129f74c](https://github.com/CorentinTh/it-tools/commit/129f74c371eaf09fdc3a19afb709cee40b7aaf7f))
* **new-tool:** hmac generator ([1bc6380](https://github.com/CorentinTh/it-tools/commit/1bc6380c6fdd7a9b500422a54bc508ab5557eb46))
### Bug Fixes
* **base64-to-string:** prevent validation error ([8a9e788](https://github.com/CorentinTh/it-tools/commit/8a9e7888dec41364c8c17b1234adcdc0616612b0))
* **bip39-generator:** typo in validation message ([7570ad9](https://github.com/CorentinTh/it-tools/commit/7570ad965602233f860b9e03177a5b9dacf1b034))
* **eta-calculator:** clamp inputs ([#249](https://github.com/CorentinTh/it-tools/issues/249)) ([531a25c](https://github.com/CorentinTh/it-tools/commit/531a25c1c4892835633ba5635c6ee48e1fbef31c))
* **wording:** removed spaces before ponctuation ([#252](https://github.com/CorentinTh/it-tools/issues/252)) ([5f03619](https://github.com/CorentinTh/it-tools/commit/5f03619ab44c0b35455c46698ec37d79e87555b5))
### Refactors
* **base64-to-file:** clean validation to convert base64 to file ([750a76b](https://github.com/CorentinTh/it-tools/commit/750a76b00fb79c0e9c2851c112141158ee0ffab1))
* **display:** mutualized code display ([0be33fb](https://github.com/CorentinTh/it-tools/commit/0be33fb337e8d82474922c0fdf9555aa328cd729))
* **lint:** externalization of prettier for simpler IDE support ([02c4963](https://github.com/CorentinTh/it-tools/commit/02c49635315661ca08deb0859c5ba33113368b9b))
* **validation:** simplified validation system ([77b5b0c](https://github.com/CorentinTh/it-tools/commit/77b5b0cab50a05dcb419ce87d74517d82e7cd2c0))
### [2.9.2](https://github.com/CorentinTh/it-tools/compare/v2.9.1...v2.9.2) (2022-07-28)
### Bug Fixes
* **base64-file:** fixed url slug ([412de23](https://github.com/CorentinTh/it-tools/commit/412de23796babbc080b0768a75029ff2ddf2acfc))
* **device-information:** handle of unknown values ([4f599b6](https://github.com/CorentinTh/it-tools/commit/4f599b699901a93444bcc67cbb3b3556a0561ae4))
* **device-information:** prevent unwanted y-truncature of text ([138149e](https://github.com/CorentinTh/it-tools/commit/138149e6f0be91255907a6083887898e5c68882e))
### Refactors
* **base64-file:** fixed typo ([1a22d55](https://github.com/CorentinTh/it-tools/commit/1a22d55b3c48f58b05b5a50de4fea260e781fbef))
### [2.9.1](https://github.com/CorentinTh/it-tools/compare/v2.9.0...v2.9.1) (2022-07-25)
### Refactors
* **base64:** split base64 text and file conversion in two tools + base64 to file ([e6953d1](https://github.com/CorentinTh/it-tools/commit/e6953d1b67b81a6d3c19973b706f29637c421f98))
## [2.9.0](https://github.com/CorentinTh/it-tools/compare/v2.8.0...v2.9.0) (2022-07-25) ## [2.9.0](https://github.com/CorentinTh/it-tools/compare/v2.8.0...v2.9.0) (2022-07-25)

View File

@@ -31,17 +31,21 @@
property="og:description" property="og:description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/> />
<meta property="og:image" content="/banner.png" /> <meta property="og:image" content="https://it-tools.tech/banner.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="it-tools.tech" /> <meta name="twitter:domain" content="it-tools.tech" />
<meta property="twitter:url" content="https://it-tools.tech/" /> <meta name="twitter:url" content="https://it-tools.tech/" />
<meta name="twitter:site" content="@ittoolsdottech" />
<meta name="twitter:creator" content="@cthmsst" />
<meta name="twitter:title" content="IT Tools - Handy online tools for developers" /> <meta name="twitter:title" content="IT Tools - Handy online tools for developers" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/> />
<meta name="twitter:image" content="/banner.png" /> <meta name="twitter:image" content="https://it-tools.tech/banner.png" />
<meta name="twitter:image:alt" content="IT Tools - Handy online tools for developers" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

7372
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "it-tools", "name": "it-tools",
"version": "2.9.0", "version": "2.10.3",
"description": "Collection of handy online tools for developers, with great UX. ", "description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [ "keywords": [
"productivity", "productivity",
@@ -47,6 +47,7 @@
"highlight.js": "^11.5.1", "highlight.js": "^11.5.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mathjs": "^10.6.0", "mathjs": "^10.6.0",
"mime-types": "^2.1.35",
"naive-ui": "^2.31.0", "naive-ui": "^2.31.0",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"plausible-tracker": "^0.3.5", "plausible-tracker": "^0.3.5",
@@ -62,10 +63,12 @@
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/jsdom": "^16.2.14", "@types/jsdom": "^16.2.14",
"@types/mime-types": "^2.1.1",
"@types/node": "^16.11.25", "@types/node": "^16.11.25",
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/randombytes": "^2.0.0", "@types/randombytes": "^2.0.0",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@typescript-eslint/parser": "^5.32.0",
"@vitejs/plugin-vue": "^2.2.2", "@vitejs/plugin-vue": "^2.2.2",
"@vitejs/plugin-vue-jsx": "^1.3.7", "@vitejs/plugin-vue-jsx": "^1.3.7",
"@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-prettier": "^7.0.0",
@@ -74,6 +77,9 @@
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"c8": "^7.11.0", "c8": "^7.11.0",
"eslint": "^8.5.0", "eslint": "^8.5.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.4.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-vue": "^8.2.0", "eslint-plugin-vue": "^8.2.0",
"jsdom": "^19.0.0", "jsdom": "^19.0.0",
"less": "^4.1.2", "less": "^4.1.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { layouts } from './layouts';
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute, RouterView } from 'vue-router'; import { useRoute, RouterView } from 'vue-router';
import { darkThemeOverrides, lightThemeOverrides } from './themes';
import { darkTheme, NGlobalStyle, NMessageProvider } from 'naive-ui'; import { darkTheme, NGlobalStyle, NMessageProvider } from 'naive-ui';
import { darkThemeOverrides, lightThemeOverrides } from './themes';
import { layouts } from './layouts';
import { useStyleStore } from './stores/style.store'; import { useStyleStore } from './stores/style.store';
const route = useRoute(); const route = useRoute();

View File

@@ -0,0 +1,98 @@
<template>
<div style="overflow-x: hidden; width: 100%">
<n-card class="result-card">
<n-scrollbar
x-scrollable
trigger="none"
:style="height ? `min-height: ${height - 40 /* card padding */ + 10 /* negative margin compensation */}px` : ''"
>
<n-config-provider :hljs="hljs">
<n-code :code="value" :language="language" :trim="false" />
</n-config-provider>
</n-scrollbar>
<n-tooltip v-if="value" trigger="hover">
<template #trigger>
<div class="copy-button" :class="[copyPlacement]">
<n-button secondary circle size="large" @click="onCopyClicked">
<n-icon size="22" :component="Copy" />
</n-button>
</div>
</template>
<span>{{ tooltipText }}</span>
</n-tooltip>
</n-card>
<n-space v-if="copyPlacement === 'outside'" justify="center" style="margin-top: 15px">
<n-button secondary @click="onCopyClicked"> {{ tooltipText }} </n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { Copy } from '@vicons/tabler';
import { useClipboard, useElementSize } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import jsonHljs from 'highlight.js/lib/languages/json';
import sqlHljs from 'highlight.js/lib/languages/sql';
import { ref, toRefs } from 'vue';
hljs.registerLanguage('sql', sqlHljs);
hljs.registerLanguage('json', jsonHljs);
const props = withDefaults(
defineProps<{
value: string;
followHeightOf?: HTMLElement | null;
language?: string;
copyPlacement?: 'top-right' | 'bottom-right' | 'outside' | 'none';
copyMessage?: string;
}>(),
{
followHeightOf: null,
language: 'txt',
copyPlacement: 'top-right',
copyMessage: 'Copy to clipboard',
},
);
const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props);
const { height } = followHeightOf ? useElementSize(followHeightOf) : { height: ref(null) };
const { copy } = useClipboard({ source: value });
const tooltipText = ref(copyMessage.value);
function onCopyClicked() {
copy();
tooltipText.value = 'Copied !';
setTimeout(() => {
tooltipText.value = copyMessage.value;
}, 2000);
}
</script>
<style lang="less" scoped>
::v-deep(.n-scrollbar) {
padding-bottom: 10px;
margin-bottom: -10px;
}
.result-card {
position: relative;
.copy-button {
position: absolute;
opacity: 1;
&.top-right {
top: 10px;
right: 10px;
}
&.bottom-right {
bottom: 10px;
right: 10px;
}
&.outside,
&.none {
display: none;
}
}
}
</style>

View File

@@ -1,11 +1,36 @@
import { extension as getExtensionFromMime } from 'mime-types';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
export function useDownloadFileFromBase64({ source, filename = 'file' }: { source: Ref<string>; filename?: string }) { function getFileExtensionFromBase64({
base64String,
defaultExtension = 'txt',
}: {
base64String: string;
defaultExtension?: string;
}) {
const hasMimeType = base64String.match(/data:(.*?);base64/i);
if (hasMimeType) {
return getExtensionFromMime(hasMimeType[1]) || defaultExtension;
}
return defaultExtension;
}
export function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
return { return {
download() { download() {
const base64String = source.value;
if (base64String === '') {
throw new Error('Base64 string is empty');
}
const cleanFileName = filename ?? `file.${getFileExtensionFromBase64({ base64String })}`;
const a = document.createElement('a'); const a = document.createElement('a');
a.href = source.value; a.href = base64String;
a.download = filename; a.download = cleanFileName;
a.click(); a.click();
}, },
}; };

View File

@@ -0,0 +1,29 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { describe, expect, it } from 'vitest';
import { isFalsyOrHasThrown } from './validation';
describe('useValidation', () => {
describe('isFalsyOrHasThrown', () => {
it('should return true if the callback return nil, false or throw', () => {
expect(isFalsyOrHasThrown(() => false)).toBe(true);
expect(isFalsyOrHasThrown(() => null)).toBe(true);
expect(isFalsyOrHasThrown(() => undefined)).toBe(true);
expect(isFalsyOrHasThrown(() => {})).toBe(true);
expect(
isFalsyOrHasThrown(() => {
throw new Error();
}),
).toBe(true);
});
it('should return true for any truthy values and empty string and 0 values', () => {
expect(isFalsyOrHasThrown(() => true)).toBe(false);
expect(isFalsyOrHasThrown(() => 'string')).toBe(false);
expect(isFalsyOrHasThrown(() => 1)).toBe(false);
expect(isFalsyOrHasThrown(() => 0)).toBe(false);
expect(isFalsyOrHasThrown(() => '')).toBe(false);
expect(isFalsyOrHasThrown(() => [])).toBe(false);
expect(isFalsyOrHasThrown(() => ({}))).toBe(false);
});
});
});

View File

@@ -1,38 +1,65 @@
import _ from 'lodash';
import { reactive, watch, type Ref } from 'vue'; import { reactive, watch, type Ref } from 'vue';
type UseValidationRule<T> = { type ValidatorReturnType = unknown;
validator: (value: T) => boolean;
message: string;
};
function isFalsyOrHasThrown(cb: () => boolean) { interface UseValidationRule<T> {
validator: (value: T) => ValidatorReturnType;
message: string;
}
export function isFalsyOrHasThrown(cb: () => ValidatorReturnType): boolean {
try { try {
return !cb(); const returnValue = cb();
if (_.isNil(returnValue)) return true;
return returnValue === false;
} catch (_) { } catch (_) {
return true; return true;
} }
} }
type ValidationAttrs = {
feedback: string;
validationStatus: string | undefined;
};
export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: UseValidationRule<T>[] }) { export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: UseValidationRule<T>[] }) {
const state = reactive<{ const state = reactive<{
message: string; message: string;
status: undefined | 'error'; status: undefined | 'error';
isValid: boolean;
attrs: ValidationAttrs;
}>({ }>({
message: '', message: '',
status: undefined, status: undefined,
isValid: false,
attrs: {
validationStatus: undefined,
feedback: '',
},
}); });
watch([source], () => { watch(
state.message = ''; [source],
state.status = undefined; () => {
state.message = '';
state.status = undefined;
for (const rule of rules) { for (const rule of rules) {
if (isFalsyOrHasThrown(() => rule.validator(source.value))) { if (isFalsyOrHasThrown(() => rule.validator(source.value))) {
state.message = rule.message; state.message = rule.message;
state.status = 'error'; state.status = 'error';
}
} }
}
}); state.isValid = state.status !== 'error';
state.attrs.feedback = state.message;
state.attrs.validationStatus = state.status;
},
{ immediate: true },
);
return state; return state;
} }

View File

@@ -4,14 +4,14 @@ import { h } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import { Heart, Menu2, Home2 } from '@vicons/tabler'; import { Heart, Menu2, Home2 } from '@vicons/tabler';
import { toolsByCategory } from '@/tools'; import { toolsByCategory } from '@/tools';
import SearchBar from '../components/SearchBar.vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue';
import { config } from '@/config'; import { config } from '@/config';
import MenuIconItem from '@/components/MenuIconItem.vue'; import MenuIconItem from '@/components/MenuIconItem.vue';
import type { ITool } from '@/tools/tool'; import type { ITool } from '@/tools/tool';
import SearchBar from '../components/SearchBar.vue';
import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue';
const themeVars = useThemeVars(); const themeVars = useThemeVars();
const route = useRoute(); const route = useRoute();

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import BaseLayout from './base.layout.vue';
import { useHead } from '@vueuse/head'; import { useHead } from '@vueuse/head';
import type { HeadObject } from '@vueuse/head'; import type { HeadObject } from '@vueuse/head';
import { computed } from 'vue'; import { computed } from 'vue';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import BaseLayout from './base.layout.vue';
const route = useRoute(); const route = useRoute();
const theme = useThemeVars(); const theme = useThemeVars();

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { createHead } from '@vueuse/head'; import { createHead } from '@vueuse/head';
// eslint-disable-next-line import/no-unresolved
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import { plausible } from './plugins/plausible.plugin'; import { plausible } from './plugins/plausible.plugin';

View File

@@ -47,7 +47,7 @@ useHead({ title: 'About - IT Tools' });
file of the repository. file of the repository.
</n-p> </n-p>
<n-h2>Found a bug ? A tool is missing ?</n-h2> <n-h2>Found a bug? A tool is missing?</n-h2>
<n-p> <n-p>
If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a
feature request in the feature request in the

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { toolsWithCategory } from '@/tools'; import { toolsWithCategory } from '@/tools';
import ToolCard from '../components/ToolCard.vue';
import { useHead } from '@vueuse/head'; import { useHead } from '@vueuse/head';
import ToolCard from '../components/ToolCard.vue';
useHead({ title: 'IT Tools - Handy online tools for developers' }); useHead({ title: 'IT Tools - Handy online tools for developers' });
</script> </script>
@@ -9,13 +9,7 @@ useHead({ title: 'IT Tools - Handy online tools for developers' });
<template> <template>
<div class="home-page"> <div class="home-page">
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8"> <n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi <n-gi v-for="tool in toolsWithCategory" :key="tool.name">
v-for="tool in [
...toolsWithCategory.filter(({ isNew }) => isNew),
...toolsWithCategory.filter(({ isNew }) => !isNew),
]"
:key="tool.name"
>
<tool-card :tool="tool" /> <tool-card :tool="tool" />
</n-gi> </n-gi>
</n-grid> </n-grid>

View File

@@ -1,5 +1,5 @@
import { layouts } from './layouts/index';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { layouts } from './layouts/index';
import HomePage from './pages/Home.page.vue'; import HomePage from './pages/Home.page.vue';
import NotFound from './pages/404.page.vue'; import NotFound from './pages/404.page.vue';
import { tools } from './tools'; import { tools } from './tools';

View File

@@ -1,9 +1,16 @@
<template> <template>
<n-card title="Text to base64"> <n-card title="Base64 to file">
<n-input v-model:value="textInput" type="textarea" placeholder="Put your string here..." /> <n-form-item
<n-input :value="textBase64" type="textarea" readonly /> :feedback="base64InputValidation.message"
:validation-status="base64InputValidation.status"
:show-label="false"
>
<n-input v-model:value="base64Input" type="textarea" placeholder="Put your base64 file string here..." rows="5" />
</n-form-item>
<n-space justify="center"> <n-space justify="center">
<n-button secondary @click="copyTextBase64()"> Copy </n-button> <n-button :disabled="base64Input === '' || !base64InputValidation.isValid" secondary @click="downloadFile()">
Download file
</n-button>
</n-space> </n-space>
</n-card> </n-card>
@@ -17,7 +24,7 @@
</n-upload-dragger> </n-upload-dragger>
</n-upload> </n-upload>
<n-input :value="fileBase64" type="textarea" readonly /> <n-input :value="fileBase64" type="textarea" readonly placeholder="File in base64 will be here" />
<n-space justify="center"> <n-space justify="center">
<n-button secondary @click="copyFileBase64()"> Copy </n-button> <n-button secondary @click="copyFileBase64()"> Copy </n-button>
</n-space> </n-space>
@@ -26,14 +33,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useBase64 } from '@vueuse/core'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useValidation } from '@/composable/validation';
import { isValidBase64 } from '@/utils/base64';
import { Upload } from '@vicons/tabler'; import { Upload } from '@vicons/tabler';
import { ref, type Ref } from 'vue'; import { useBase64 } from '@vueuse/core';
import type { UploadFileInfo } from 'naive-ui'; import type { UploadFileInfo } from 'naive-ui';
import { ref, type Ref } from 'vue';
const textInput = ref(''); const base64Input = ref('');
const { base64: textBase64 } = useBase64(textInput); const { download } = useDownloadFileFromBase64({ source: base64Input });
const { copy: copyTextBase64 } = useCopy({ source: textBase64, text: 'Base64 string copied to the clipboard' }); const base64InputValidation = useValidation({
source: base64Input,
rules: [
{
message: 'Invalid base 64 string',
validator: (value) => isValidBase64(value.trim()),
},
],
});
function downloadFile() {
if (!base64InputValidation.isValid) return;
try {
download();
} catch (_) {
//
}
}
const fileList = ref(); const fileList = ref();
const fileInput = ref() as Ref<File>; const fileInput = ref() as Ref<File>;

View File

@@ -2,11 +2,10 @@ import { FileDigit } from '@vicons/tabler';
import { defineTool } from '../tool'; import { defineTool } from '../tool';
export const tool = defineTool({ export const tool = defineTool({
name: 'Base64 converter', name: 'Base64 file converter',
path: '/base64-converter', path: '/base64-file-converter',
description: "Convert string, files or images into a it's base64 representation.", description: "Convert string, files or images into a it's base64 representation.",
keywords: ['base64', 'converter', 'upload', 'image', 'file', 'conversion', 'web', 'data', 'format'], keywords: ['base64', 'converter', 'upload', 'image', 'file', 'conversion', 'web', 'data', 'format'],
component: () => import('./base64-converter.vue'), component: () => import('./base64-file-converter.vue'),
icon: FileDigit, icon: FileDigit,
redirectFrom: ['/file-to-base64', '/base64-string-converter'],
}); });

View File

@@ -0,0 +1,57 @@
<template>
<n-card title="String to base64">
<n-form-item label="String to encode">
<n-input v-model:value="textInput" type="textarea" placeholder="Put your string here..." rows="5" />
</n-form-item>
<n-form-item label="Base64 of string">
<n-input
:value="base64Output"
type="textarea"
readonly
placeholder="The base64 encoding of your string will be here"
rows="5"
/>
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copyTextBase64()"> Copy base64 </n-button>
</n-space>
</n-card>
<n-card title="Base64 to string">
<n-form-item label="Base64 string to decode" v-bind="b64Validation.attrs">
<n-input v-model:value="base64Input" type="textarea" placeholder="Your base64 string..." rows="5" />
</n-form-item>
<n-form-item label="Decoded string">
<n-input :value="textOutput" type="textarea" readonly placeholder="The decoded string will be here" rows="5" />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copyText()"> Copy decoded string </n-button>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { useValidation } from '@/composable/validation';
import { base64ToText, isValidBase64, textToBase64 } from '@/utils/base64';
import { withDefaultOnError } from '@/utils/defaults';
import { computed, ref } from 'vue';
const textInput = ref('');
const base64Output = computed(() => textToBase64(textInput.value));
const { copy: copyTextBase64 } = useCopy({ source: base64Output, text: 'Base64 string copied to the clipboard' });
const base64Input = ref('');
const textOutput = computed(() => withDefaultOnError(() => base64ToText(base64Input.value.trim()), ''));
const { copy: copyText } = useCopy({ source: textOutput, text: 'String copied to the clipboard' });
const b64Validation = useValidation({
source: base64Input,
rules: [{ message: 'Invalid base64 string', validator: (value) => isValidBase64(value.trim()) }],
});
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,12 @@
import { FileDigit } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Base64 string encoder/decoder',
path: '/base64-string-converter',
description: 'Simply encode and decode string into a their base64 representation.',
keywords: ['base64', 'converter', 'conversion', 'web', 'data', 'format', 'atob', 'btoa'],
component: () => import('./base64-string-converter.vue'),
icon: FileDigit,
redirectFrom: ['/file-to-base64', '/base64-converter'],
});

View File

@@ -30,11 +30,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { textToBase64 } from '@/utils/base64';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const header = computed(() => `Authorization: Basic ${window.btoa(`${username.value}:${password.value}`)}`); const header = computed(() => `Authorization: Basic ${textToBase64(`${username.value}:${password.value}`)}`);
const { copy } = useCopy({ source: header, text: 'Header copied to the clipboard' }); const { copy } = useCopy({ source: header, text: 'Header copied to the clipboard' });
</script> </script>

View File

@@ -59,24 +59,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { ref, computed } from 'vue'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
import { import {
entropyToMnemonic,
englishWordList,
chineseSimplifiedWordList, chineseSimplifiedWordList,
chineseTraditionalWordList, chineseTraditionalWordList,
czechWordList, czechWordList,
englishWordList,
entropyToMnemonic,
frenchWordList, frenchWordList,
generateEntropy,
italianWordList, italianWordList,
japaneseWordList, japaneseWordList,
koreanWordList, koreanWordList,
mnemonicToEntropy,
portugueseWordList, portugueseWordList,
spanishWordList, spanishWordList,
generateEntropy,
mnemonicToEntropy,
} from '@it-tools/bip39'; } from '@it-tools/bip39';
import { Copy, Refresh } from '@vicons/tabler'; import { Copy, Refresh } from '@vicons/tabler';
import { useValidation } from '@/composable/validation'; import { computed, ref } from 'vue';
const languages = { const languages = {
English: englishWordList, English: englishWordList,
@@ -97,20 +99,11 @@ const passphraseInput = ref('');
const language = ref<keyof typeof languages>('English'); const language = ref<keyof typeof languages>('English');
const passphrase = computed({ const passphrase = computed({
get() { get() {
try { return withDefaultOnError(() => entropyToMnemonic(entropy.value, languages[language.value]), passphraseInput.value);
return entropyToMnemonic(entropy.value, languages[language.value]);
} catch (_) {
return passphraseInput.value;
}
}, },
set(value: string) { set(value: string) {
passphraseInput.value = value; passphraseInput.value = value;
entropy.value = withDefaultOnError(() => mnemonicToEntropy(value, languages[language.value]), '');
try {
entropy.value = mnemonicToEntropy(value, languages[language.value]);
} catch (_) {
entropy.value = '';
}
}, },
}); });
@@ -123,7 +116,7 @@ const entropyValidation = useValidation({
}, },
{ {
validator: (value) => /^[a-fA-F0-9]*$/.test(value), validator: (value) => /^[a-fA-F0-9]*$/.test(value),
message: 'Entropy should an hexadecimal number', message: 'Entropy should be an hexadecimal string',
}, },
], ],
}); });
@@ -132,14 +125,7 @@ const mnemonicValidation = useValidation({
source: passphrase, source: passphrase,
rules: [ rules: [
{ {
validator: (value) => { validator: (value) => isNotThrowing(() => mnemonicToEntropy(value, languages[language.value])),
try {
mnemonicToEntropy(value);
return true;
} catch (_) {
return false;
}
},
message: 'Invalid mnemonic', message: 'Invalid mnemonic',
}, },
], ],

View File

@@ -46,8 +46,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue';
import { import {
camelCase, camelCase,
capitalCase, capitalCase,
@@ -61,6 +59,7 @@ import {
sentenceCase, sentenceCase,
snakeCase, snakeCase,
} from 'change-case'; } from 'change-case';
import InputCopyable from '../../components/InputCopyable.vue';
const input = ref('lorem ipsum dolor sit amet'); const input = ref('lorem ipsum dolor sit amet');
</script> </script>

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatChronometerTime } from './chronometer.service'; import { formatMs } from './chronometer.service';
describe('chronometer', () => { describe('chronometer', () => {
describe('formatChronometerTime', () => { describe('formatChronometerTime', () => {
it('format the elapsed time', () => { it('format the elapsed time', () => {
expect(formatChronometerTime({ elapsed: 123456 })).toEqual('02:03.456'); expect(formatMs(0)).toEqual('00:00.000');
expect(formatChronometerTime({ elapsed: 123456, msPerUnit: 100 })).toEqual('03:25:45.600'); expect(formatMs(1)).toEqual('00:00.001');
expect(formatChronometerTime({ elapsed: 12345600 })).toEqual('03:25:45.600'); expect(formatMs(123456)).toEqual('02:03.456');
expect(formatMs(12345600)).toEqual('03:25:45.600');
}); });
}); });
}); });

View File

@@ -1,10 +1,8 @@
export function formatChronometerTime({ elapsed, msPerUnit = 1 }: { elapsed: number; msPerUnit?: number }) { export function formatMs(msTotal: number) {
const elapsedMs = elapsed * msPerUnit; const ms = msTotal % 1000;
const secs = ((msTotal - ms) / 1000) % 60;
const ms = elapsedMs % 1000; const mins = (((msTotal - ms) / 1000 - secs) / 60) % 60;
const secs = ((elapsedMs - ms) / 1000) % 60; const hrs = (((msTotal - ms) / 1000 - secs) / 60 - mins) / 60;
const mins = (((elapsedMs - ms) / 1000 - secs) / 60) % 60;
const hrs = (((elapsedMs - ms) / 1000 - secs) / 60 - mins) / 60;
const hrsString = hrs > 0 ? `${hrs.toString().padStart(2, '0')}:` : ''; const hrsString = hrs > 0 ? `${hrs.toString().padStart(2, '0')}:` : '';
return `${hrsString}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms return `${hrsString}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms

View File

@@ -1,11 +1,11 @@
<template> <template>
<div> <div>
<n-card> <n-card>
<div class="duration">{{ formatChronometerTime({ elapsed: counter, msPerUnit }) }}</div> <div class="duration">{{ formatMs(counter) }}</div>
</n-card> </n-card>
<br /> <br />
<n-space justify="center"> <n-space justify="center">
<n-button v-if="!isActive" secondary type="primary" @click="resume">Start</n-button> <n-button v-if="!isRunning" secondary type="primary" @click="resume">Start</n-button>
<n-button v-else secondary type="warning" @click="pause">Stop</n-button> <n-button v-else secondary type="warning" @click="pause">Stop</n-button>
<n-button secondary @click="counter = 0">Reset</n-button> <n-button secondary @click="counter = 0">Reset</n-button>
@@ -14,12 +14,33 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useInterval } from '@vueuse/core'; import { useRafFn } from '@vueuse/core';
import { formatChronometerTime } from './chronometer.service'; import { ref } from 'vue';
import { formatMs } from './chronometer.service';
const msPerUnit = 10; const isRunning = ref(false);
const counter = ref(0);
const { counter, pause, resume, isActive } = useInterval(msPerUnit, { controls: true, immediate: false }); let previousRafDate = Date.now();
const { pause: pauseRaf, resume: resumeRaf } = useRafFn(
() => {
const deltaMs = Date.now() - previousRafDate;
previousRafDate = Date.now();
counter.value += deltaMs;
},
{ immediate: false },
);
function resume() {
previousRafDate = Date.now();
resumeRaf();
isRunning.value = true;
}
function pause() {
pauseRaf();
isRunning.value = false;
}
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@@ -36,12 +36,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { colord, extend } from 'colord'; import { colord, extend } from 'colord';
import InputCopyable from '../../components/InputCopyable.vue';
import cmykPlugin from 'colord/plugins/cmyk'; import cmykPlugin from 'colord/plugins/cmyk';
import hwbPlugin from 'colord/plugins/hwb'; import hwbPlugin from 'colord/plugins/hwb';
import namesPlugin from 'colord/plugins/names'; import namesPlugin from 'colord/plugins/names';
import lchPlugin from 'colord/plugins/lch'; import lchPlugin from 'colord/plugins/lch';
import InputCopyable from '../../components/InputCopyable.vue';
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]); extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);

View File

@@ -1,16 +1,17 @@
<template> <template>
<n-card v-for="{ name, information } in sections" :key="name" :title="name"> <n-card v-for="{ name, information } in sections" :key="name" :title="name">
<n-grid cols="1 400:2" x-gap="12" y-gap="12"> <n-grid cols="1 400:2" x-gap="12" y-gap="12">
<n-gi v-for="{ label, value } in information" :key="label" class="information"> <n-gi v-for="{ label, value: { value } } in information" :key="label" class="information">
<n-card :bordered="false" embedded> <n-card :bordered="false" embedded>
<div class="label"> <div class="label">
{{ label }} {{ label }}
</div> </div>
<div class="value"> <div class="value">
<n-ellipsis> <n-ellipsis v-if="value">
{{ value.value }} {{ value }}
</n-ellipsis> </n-ellipsis>
<div v-else class="undefined-value">unknown</div>
</div> </div>
</n-card> </n-card>
</n-gi> </n-gi>
@@ -89,7 +90,10 @@ const sections = [
.value { .value {
font-size: 20px; font-size: 20px;
font-weight: 400; font-weight: 400;
line-height: 1; }
.undefined-value {
opacity: 0.8;
} }
} }
</style> </style>

View File

@@ -11,7 +11,7 @@
<div> <div>
<n-space item-style="flex:1 1 0"> <n-space item-style="flex:1 1 0">
<n-form-item label="Amount of element to consume"> <n-form-item label="Amount of element to consume">
<n-input-number v-model:value="unitCount" /> <n-input-number v-model:value="unitCount" :min="1" />
</n-form-item> </n-form-item>
<n-form-item label="The consumption started at"> <n-form-item label="The consumption started at">
<n-date-picker v-model:value="startedAt" type="datetime" /> <n-date-picker v-model:value="startedAt" type="datetime" />
@@ -19,10 +19,10 @@
</n-space> </n-space>
<n-form-item label="Amount of unit consumed by time span" :show-feedback="false"> <n-form-item label="Amount of unit consumed by time span" :show-feedback="false">
<n-input-number v-model:value="unitPerTimeSpan" /> <n-input-number v-model:value="unitPerTimeSpan" :min="1" />
<span style="margin: 0 10px">in</span> <span style="margin: 0 10px">in</span>
<n-input-group> <n-input-group>
<n-input-number v-model:value="timeSpan" /> <n-input-number v-model:value="timeSpan" :min="1" />
<n-select <n-select
v-model:value="timeSpanUnitMultiplier" v-model:value="timeSpanUnitMultiplier"
:options="[ :options="[
@@ -51,7 +51,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Duplicate issue with sub directory
// eslint-disable-next-line import/no-duplicates
import { addMilliseconds, formatRelative } from 'date-fns'; import { addMilliseconds, formatRelative } from 'date-fns';
// eslint-disable-next-line import/no-duplicates
import { enGB } from 'date-fns/locale'; import { enGB } from 'date-fns/locale';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { formatMsDuration } from './eta-calculator.service'; import { formatMsDuration } from './eta-calculator.service';

View File

@@ -5,8 +5,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Memo from './git-memo.md';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import Memo from './git-memo.md';
const themeVars = useThemeVars(); const themeVars = useThemeVars();
</script> </script>

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { convertHexToBin } from './hash-text.service';
describe('hash text', () => {
describe('convertHexToBin', () => {
it('convert hex to bin', () => {
expect(convertHexToBin('')).toEqual('');
expect(convertHexToBin('FF')).toEqual('11111111');
expect(convertHexToBin('F'.repeat(200))).toEqual('1111'.repeat(200));
expect(convertHexToBin('2123006AD00F694CE120')).toEqual(
'00100001001000110000000001101010110100000000111101101001010011001110000100100000',
);
});
});
});

View File

@@ -0,0 +1,7 @@
export function convertHexToBin(hex: string) {
return hex
.trim()
.split('')
.map((byte) => parseInt(byte, 16).toString(2).padStart(4, '0'))
.join('');
}

View File

@@ -1,10 +1,34 @@
<template> <template>
<div> <div>
<n-card> <n-card>
<n-input v-model:value="clearText" type="textarea" placeholder="Your string..." :autosize="{ minRows: 3 }" /> <n-input v-model:value="clearText" type="textarea" placeholder="Your string to hash..." rows="3" />
<n-divider /> <n-divider />
<n-form-item label="Digest encoding">
<n-select
v-model:value="encoding"
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
<div v-for="algo in algoNames" :key="algo" style="margin: 5px 0"> <div v-for="algo in algoNames" :key="algo" style="margin: 5px 0">
<n-input-group> <n-input-group>
<n-input-group-label style="flex: 0 0 120px"> {{ algo }} </n-input-group-label> <n-input-group-label style="flex: 0 0 120px"> {{ algo }} </n-input-group-label>
@@ -16,9 +40,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import InputCopyable from '../../components/InputCopyable.vue'; import { enc, lib, MD5, RIPEMD160, SHA1, SHA224, SHA256, SHA3, SHA384, SHA512 } from 'crypto-js';
import { ref } from 'vue'; import { ref } from 'vue';
import { MD5, SHA1, SHA256, SHA224, SHA512, SHA384, SHA3, RIPEMD160 } from 'crypto-js'; import InputCopyable from '../../components/InputCopyable.vue';
import { convertHexToBin } from './hash-text.service';
const algos = { const algos = {
MD5, MD5,
@@ -32,10 +57,18 @@ const algos = {
} as const; } as const;
type AlgoNames = keyof typeof algos; type AlgoNames = keyof typeof algos;
type Encoding = keyof typeof enc | 'Bin';
const algoNames = Object.keys(algos) as AlgoNames[]; const algoNames = Object.keys(algos) as AlgoNames[];
const encoding = ref<Encoding>('Hex');
const clearText = ref('');
const clearText = ref( function formatWithEncoding(words: lib.WordArray, encoding: Encoding) {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lacus metus blandit dolor lacus natoque ad fusce aliquam velit.', if (encoding === 'Bin') {
); return convertHexToBin(words.toString(enc.Hex));
const hashText = (algo: AlgoNames, value: string) => algos[algo](value).toString(); }
return words.toString(enc[encoding]);
}
const hashText = (algo: AlgoNames, value: string) => formatWithEncoding(algos[algo](value), encoding.value);
</script> </script>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<n-form-item label="Plain text to compute the hash">
<n-input v-model:value="plainText" type="textarea" placeholder="Enter the text to compute the hash..." />
</n-form-item>
<n-form-item label="Secret key">
<n-input v-model:value="secret" placeholder="Enter the secret key..." />
</n-form-item>
<n-space item-style="flex:1 1 0">
<n-form-item label="Hashing function">
<n-select
v-model:value="hashFunction"
placeholder="Select an hashing function..."
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<n-form-item label="Output encoding">
<n-select
v-model:value="encoding"
placeholder="Select the result encoding..."
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64-url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
</n-space>
<n-form-item label="HMAC of your text">
<n-input readonly :value="hmac" type="textarea" placeholder="The result of the HMAC..." />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copy()">Copy HMAC</n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import {
enc,
HmacMD5,
HmacRIPEMD160,
HmacSHA1,
HmacSHA224,
HmacSHA256,
HmacSHA3,
HmacSHA384,
HmacSHA512,
lib,
} from 'crypto-js';
import { computed, ref } from 'vue';
import { convertHexToBin } from '../hash-text/hash-text.service';
const algos = {
MD5: HmacMD5,
RIPEMD160: HmacRIPEMD160,
SHA1: HmacSHA1,
SHA3: HmacSHA3,
SHA224: HmacSHA224,
SHA256: HmacSHA256,
SHA384: HmacSHA384,
SHA512: HmacSHA512,
} as const;
type Encoding = keyof typeof enc | 'Bin';
function formatWithEncoding(words: lib.WordArray, encoding: Encoding) {
if (encoding === 'Bin') {
return convertHexToBin(words.toString(enc.Hex));
}
return words.toString(enc[encoding]);
}
const plainText = ref('');
const secret = ref('');
const hashFunction = ref<keyof typeof algos>('SHA256');
const encoding = ref<Encoding>('Hex');
const hmac = computed(() =>
formatWithEncoding(algos[hashFunction.value](plainText.value, secret.value), encoding.value),
);
const { copy } = useCopy({ source: hmac });
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,12 @@
import { ShortTextRound } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Hmac generator',
path: '/hmac-generator',
description:
'Computes a hash-based message authentication code (HMAC) using a secret key and your favorite hashing function.',
keywords: ['hmac', 'generator', 'MD5', 'SHA1', 'SHA256', 'SHA224', 'SHA512', 'SHA384', 'SHA3', 'RIPEMD160'],
component: () => import('./hmac-generator.vue'),
icon: ShortTextRound,
});

View File

@@ -1,7 +1,8 @@
import { LockOpen } from '@vicons/tabler'; import { LockOpen } from '@vicons/tabler';
import type { ToolCategory } from './tool'; import type { ToolCategory } from './tool';
import { tool as base64Converter } from './base64-converter'; 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 basicAuthGenerator } from './basic-auth-generator';
import { tool as bcrypt } from './bcrypt'; import { tool as bcrypt } from './bcrypt';
import { tool as bip39 } from './bip39-generator'; import { tool as bip39 } from './bip39-generator';
@@ -15,6 +16,7 @@ import { tool as cypher } from './encryption';
import { tool as etaCalculator } from './eta-calculator'; import { tool as etaCalculator } from './eta-calculator';
import { tool as gitMemo } from './git-memo'; import { tool as gitMemo } from './git-memo';
import { tool as hashText } from './hash-text'; import { tool as hashText } from './hash-text';
import { tool as hmacGenerator } from './hmac-generator';
import { tool as htmlEntities } from './html-entities'; import { tool as htmlEntities } from './html-entities';
import { tool as baseConverter } from './integer-base-converter'; import { tool as baseConverter } from './integer-base-converter';
import { tool as jsonViewer } from './json-viewer'; import { tool as jsonViewer } from './json-viewer';
@@ -24,6 +26,7 @@ import { tool as qrCodeGenerator } from './qr-code-generator';
import { tool as randomPortGenerator } from './random-port-generator'; import { tool as randomPortGenerator } from './random-port-generator';
import { tool as romanNumeralConverter } from './roman-numeral-converter'; import { tool as romanNumeralConverter } from './roman-numeral-converter';
import { tool as sqlPrettify } from './sql-prettify'; import { tool as sqlPrettify } from './sql-prettify';
import { tool as svgPlaceholderGenerator } from './svg-placeholder-generator';
import { tool as textStatistics } from './text-statistics'; import { tool as textStatistics } from './text-statistics';
import { tool as tokenGenerator } from './token-generator'; import { tool as tokenGenerator } from './token-generator';
import { tool as urlEncoder } from './url-encoder'; import { tool as urlEncoder } from './url-encoder';
@@ -34,7 +37,7 @@ export const toolsByCategory: ToolCategory[] = [
{ {
name: 'Crypto', name: 'Crypto',
icon: LockOpen, icon: LockOpen,
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39], components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39, hmacGenerator],
}, },
{ {
name: 'Converter', name: 'Converter',
@@ -43,7 +46,8 @@ export const toolsByCategory: ToolCategory[] = [
dateTimeConverter, dateTimeConverter,
baseConverter, baseConverter,
romanNumeralConverter, romanNumeralConverter,
base64Converter, base64StringConverter,
base64FileConverter,
colorConverter, colorConverter,
caseConverter, caseConverter,
], ],
@@ -51,7 +55,12 @@ export const toolsByCategory: ToolCategory[] = [
{ {
name: 'Web', name: 'Web',
icon: LockOpen, icon: LockOpen,
components: [urlEncoder, htmlEntities, qrCodeGenerator, urlParser, deviceInformation, basicAuthGenerator], components: [urlEncoder, htmlEntities, urlParser, deviceInformation, basicAuthGenerator],
},
{
name: 'Images',
icon: LockOpen,
components: [qrCodeGenerator, svgPlaceholderGenerator],
}, },
{ {
name: 'Development', name: 'Development',

View File

@@ -67,9 +67,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useStyleStore } from '@/stores/style.store';
import { convertBase } from './integer-base-converter.model'; import { convertBase } from './integer-base-converter.model';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
import { useStyleStore } from '@/stores/style.store';
const styleStore = useStyleStore(); const styleStore = useStyleStore();

View File

@@ -17,44 +17,28 @@
/> />
</n-form-item> </n-form-item>
<n-form-item label="Prettify version of your json"> <n-form-item label="Prettify version of your json">
<n-card class="result-card" :style="`min-height: ${inputElementHeight ?? 400}px`"> <textarea-copyable :value="cleanJson" language="json" :follow-height-of="inputElement" />
<n-config-provider :hljs="hljs">
<n-code :code="cleanJson" language="json" :trim="false" />
</n-config-provider>
<n-button v-if="cleanJson" class="copy-button" secondary @click="copy">Copy</n-button>
</n-card>
</n-form-item> </n-form-item>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { useElementSize } from '@vueuse/core'; import { withDefaultOnError } from '@/utils/defaults';
import hljs from 'highlight.js/lib/core'; import JSON5 from 'json5';
import json from 'highlight.js/lib/languages/json';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
hljs.registerLanguage('json', json);
const inputElement = ref<HTMLElement>(); const inputElement = ref<HTMLElement>();
const { height: inputElementHeight } = useElementSize(inputElement);
const rawJson = ref('{"hello": "world"}'); const rawJson = ref('{"hello": "world"}');
const cleanJson = computed(() => { const cleanJson = computed(() => withDefaultOnError(() => JSON.stringify(JSON5.parse(rawJson.value), null, 3), ''));
try {
return JSON.stringify(JSON.parse(rawJson.value), null, 3);
} catch (_) {
return '';
}
});
const { copy } = useCopy({ source: cleanJson });
const rawJsonValidation = useValidation({ const rawJsonValidation = useValidation({
source: rawJson, source: rawJson,
rules: [ rules: [
{ {
validator: (v) => v === '' || JSON.parse(v), validator: (v) => v === '' || JSON5.parse(v),
message: 'Invalid json', message: 'Provided JSON is not valid.',
}, },
], ],
}); });

View File

@@ -30,8 +30,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
import { randIntFromInterval } from '@/utils/random'; import { randIntFromInterval } from '@/utils/random';
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
const paragraphs = ref(1); const paragraphs = ref(1);
const sentences = ref([3, 8]); const sentences = ref([3, 8]);

View File

@@ -21,18 +21,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { withDefaultOnError } from '@/utils/defaults';
import { evaluate } from 'mathjs'; import { evaluate } from 'mathjs';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
const expression = ref(''); const expression = ref('');
const result = computed(() => { const result = computed(() => withDefaultOnError(() => evaluate(expression.value) ?? '', ''));
try {
return evaluate(expression.value) ?? '';
} catch (_) {
return '';
}
});
</script> </script>
<style lang="less" scoped></style> <style lang="less" scoped></style>

View File

@@ -32,9 +32,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useQRCode } from './useQRCode';
import { ref } from 'vue'; import { ref } from 'vue';
import type { QRCodeErrorCorrectionLevel } from 'qrcode'; import type { QRCodeErrorCorrectionLevel } from 'qrcode';
import { useQRCode } from './useQRCode';
const foreground = ref('#000000ff'); const foreground = ref('#000000ff');
const background = ref('#ffffffff'); const background = ref('#ffffffff');

View File

@@ -58,27 +58,17 @@
/> />
</n-form-item> </n-form-item>
<n-form-item label="Prettify version of your query"> <n-form-item label="Prettify version of your query">
<n-card class="result-card" :style="`min-height: ${inputElementHeight ?? 400}px`"> <textarea-copyable :value="prettySQL" language="sql" :follow-height-of="inputElement" />
<n-config-provider :hljs="hljs">
<n-code :code="prettySQL" language="sql" :trim="false" />
</n-config-provider>
<n-button v-if="prettySQL" class="copy-button" secondary @click="copy">Copy</n-button>
</n-card>
</n-form-item> </n-form-item>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
import { useElementSize } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import sqlHljs from 'highlight.js/lib/languages/sql';
import { format as formatSQL, type FormatFnOptions } from 'sql-formatter'; import { format as formatSQL, type FormatFnOptions } from 'sql-formatter';
import { computed, reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
hljs.registerLanguage('sql', sqlHljs);
const inputElement = ref<HTMLElement>(); const inputElement = ref<HTMLElement>();
const { height: inputElementHeight } = useElementSize(inputElement);
const styleStore = useStyleStore(); const styleStore = useStyleStore();
const config = reactive<Partial<FormatFnOptions>>({ const config = reactive<Partial<FormatFnOptions>>({
keywordCase: 'upper', keywordCase: 'upper',
@@ -90,7 +80,6 @@ const config = reactive<Partial<FormatFnOptions>>({
const rawSQL = ref('select field1,field2,field3 from my_table where my_condition;'); const rawSQL = ref('select field1,field2,field3 from my_table where my_condition;');
const prettySQL = computed(() => formatSQL(rawSQL.value, config)); const prettySQL = computed(() => formatSQL(rawSQL.value, config));
const { copy } = useCopy({ source: prettySQL });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@@ -0,0 +1,11 @@
import { ImageOutlined } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'SVG placeholder generator',
path: '/svg-placeholder-generator',
description: 'Generate svg images to use as placeholder in your applications.',
keywords: ['svg', 'placeholder', 'generator', 'image', 'size', 'mockup'],
component: () => import('./svg-placeholder-generator.vue'),
icon: ImageOutlined,
});

View File

@@ -0,0 +1,90 @@
<template>
<div>
<n-form label-placement="left" label-width="100">
<n-space item-style="flex:1 1 0">
<n-form-item label="Width (in px)">
<n-input-number v-model:value="width" placeholder="SVG width..." min="1" />
</n-form-item>
<n-form-item label="Background">
<n-color-picker v-model:value="bgColor" :modes="['hex']" />
</n-form-item>
</n-space>
<n-space item-style="flex:1 1 0">
<n-form-item label="Height (in px)">
<n-input-number v-model:value="height" placeholder="SVG height..." min="1" />
</n-form-item>
<n-form-item label="Text color">
<n-color-picker v-model:value="fgColor" :modes="['hex']" />
</n-form-item>
</n-space>
<n-space item-style="flex:1 1 0">
<n-form-item label="Font size">
<n-input-number v-model:value="fontSize" placeholder="Font size..." min="1" />
</n-form-item>
<n-form-item label="Custom text">
<n-input v-model:value="customText" :placeholder="`Default is ${width}x${height}`" />
</n-form-item>
</n-space>
<n-form-item label="Use exact size" label-placement="left">
<n-switch v-model:value="useExactSize" />
</n-form-item>
</n-form>
<n-form-item label="SVG HTML element">
<textarea-copyable :value="svgString" copy-placement="none" />
</n-form-item>
<n-form-item label="SVG in Base64">
<textarea-copyable :value="base64" copy-placement="none" />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copySVG()">Copy svg</n-button>
<n-button secondary @click="copyBase64()">Copy base64</n-button>
<n-button secondary @click="download()">Download svg</n-button>
</n-space>
</div>
<n-space vertical justify="start">
<img :src="base64" alt="Image" />
</n-space>
</template>
<script setup lang="ts">
import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useCopy } from '@/composable/copy';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { textToBase64 } from '@/utils/base64';
import { computed, ref } from 'vue';
const width = ref(600);
const height = ref(350);
const fontSize = ref(26);
const bgColor = ref('#cccccc');
const fgColor = ref('#333333');
const useExactSize = ref(true);
const customText = ref('');
const svgString = computed(() => {
const w = width.value;
const h = height.value;
const text = customText.value.length > 0 ? customText.value : `${w}x${h}`;
const size = useExactSize.value ? ` width="${w}" height="${h}"` : '';
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"${size}>
<rect width="${w}" height="${h}" fill="${bgColor.value}"></rect>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="monospace" font-size="${fontSize.value}px" fill="${fgColor.value}">${text}</text>
</svg>
`.trim();
});
const base64 = computed(() => 'data:image/svg+xml;base64,' + textToBase64(svgString.value));
const { copy: copySVG } = useCopy({ source: svgString });
const { copy: copyBase64 } = useCopy({ source: base64 });
const { download } = useDownloadFileFromBase64({ source: base64 });
</script>
<style lang="less" scoped>
.n-input-number {
width: 100%;
}
</style>

View File

@@ -60,29 +60,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
const encodeInput = ref('Hello world :)'); const encodeInput = ref('Hello world :)');
const encodeOutput = computed(() => { const encodeOutput = computed(() => withDefaultOnError(() => encodeURIComponent(encodeInput.value), ''));
try {
return encodeURIComponent(encodeInput.value);
} catch (_) {
return '';
}
});
const encodedValidation = useValidation({ const encodedValidation = useValidation({
source: encodeInput, source: encodeInput,
rules: [ rules: [
{ {
validator: (value) => { validator: (value) => isNotThrowing(() => encodeURIComponent(value)),
try {
encodeURIComponent(value);
return true;
} catch (_) {
return false;
}
},
message: 'Impossible to parse this string', message: 'Impossible to parse this string',
}, },
], ],
@@ -91,27 +80,13 @@ const encodedValidation = useValidation({
const { copy: copyEncoded } = useCopy({ source: encodeOutput, text: 'Encoded string copied to the clipboard' }); const { copy: copyEncoded } = useCopy({ source: encodeOutput, text: 'Encoded string copied to the clipboard' });
const decodeInput = ref('Hello%20world%20%3A)'); const decodeInput = ref('Hello%20world%20%3A)');
const decodeOutput = computed(() => withDefaultOnError(() => decodeURIComponent(decodeInput.value), ''));
const decodeOutput = computed(() => {
try {
return decodeURIComponent(decodeInput.value);
} catch (_) {
return '';
}
});
const decodeValidation = useValidation({ const decodeValidation = useValidation({
source: encodeInput, source: encodeInput,
rules: [ rules: [
{ {
validator: (value) => { validator: (value) => isNotThrowing(() => decodeURIComponent(value)),
try {
decodeURIComponent(value);
return true;
} catch (_) {
return false;
}
},
message: 'Impossible to parse this string', message: 'Impossible to parse this string',
}, },
], ],

View File

@@ -27,31 +27,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { SubdirectoryArrowRightRound } from '@vicons/material';
import InputCopyable from '../../components/InputCopyable.vue';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
import { SubdirectoryArrowRightRound } from '@vicons/material';
import { computed, ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue';
const urlToParse = ref('https://me:pwd@it-tools.tech:3000/url-parser?key1=value&key2=value2#the-hash'); const urlToParse = ref('https://me:pwd@it-tools.tech:3000/url-parser?key1=value&key2=value2#the-hash');
const urlParsed = computed<URL | undefined>(() => {
try { const urlParsed = computed(() => withDefaultOnError(() => new URL(urlToParse.value), undefined));
return new URL(urlToParse.value);
} catch (_) {
return undefined;
}
});
const validation = useValidation({ const validation = useValidation({
source: urlToParse, source: urlToParse,
rules: [ rules: [
{ {
validator: (value) => { validator: (value) => isNotThrowing(() => new URL(value)),
try {
new URL(value);
return true;
} catch (_) {
return false;
}
},
message: 'Invalid url', message: 'Invalid url',
}, },
], ],

69
src/utils/base64.test.ts Normal file
View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { base64ToText, isValidBase64, removePotentialDataAndMimePrefix, textToBase64 } from './base64';
describe('base64 utils', () => {
describe('textToBase64', () => {
it('should convert string into base64', () => {
expect(textToBase64('')).to.eql('');
expect(textToBase64('a')).to.eql('YQ==');
expect(textToBase64('lorem ipsum')).to.eql('bG9yZW0gaXBzdW0=');
expect(textToBase64('-1')).to.eql('LTE=');
});
});
describe('base64ToText', () => {
it('should convert base64 into text', () => {
expect(base64ToText('')).to.eql('');
expect(base64ToText('YQ==')).to.eql('a');
expect(base64ToText('bG9yZW0gaXBzdW0=')).to.eql('lorem ipsum');
expect(base64ToText('data:text/plain;base64,bG9yZW0gaXBzdW0=')).to.eql('lorem ipsum');
expect(base64ToText('LTE=')).to.eql('-1');
});
it('should throw for incorrect base64 string', () => {
expect(() => base64ToText('a')).to.throw('Incorrect base64 string');
expect(() => base64ToText(' ')).to.throw('Incorrect base64 string');
expect(() => base64ToText('é')).to.throw('Incorrect base64 string');
// missing final '='
expect(() => base64ToText('bG9yZW0gaXBzdW0')).to.throw('Incorrect base64 string');
});
});
describe('isValidBase64', () => {
it('should return true for correct base64 string', () => {
expect(isValidBase64('')).to.eql(true);
expect(isValidBase64('bG9yZW0gaXBzdW0=')).to.eql(true);
expect(isValidBase64('LTE=')).to.eql(true);
expect(isValidBase64('YQ==')).to.eql(true);
expect(isValidBase64('data:text/plain;base64,YQ==')).to.eql(true);
});
it('should return false for incorrect base64 string', () => {
expect(isValidBase64('a')).to.eql(false);
expect(isValidBase64(' ')).to.eql(false);
expect(isValidBase64('é')).to.eql(false);
expect(isValidBase64('data:text/plain;notbase64,YQ==')).to.eql(false);
// missing final '='
expect(isValidBase64('bG9yZW0gaXBzdW0')).to.eql(false);
});
it('should return false for untrimmed correct base64 string', () => {
expect(isValidBase64('bG9yZW0gaXBzdW0= ')).to.eql(false);
expect(isValidBase64(' LTE=')).to.eql(false);
expect(isValidBase64(' YQ== ')).to.eql(false);
});
});
describe('removePotentialDataAndMimePrefix', () => {
it('should remove data prefix of string', () => {
expect(removePotentialDataAndMimePrefix('')).to.eql('');
expect(removePotentialDataAndMimePrefix('lorem ipsum')).to.eql('lorem ipsum');
expect(removePotentialDataAndMimePrefix('bG9yZW0gaXBzdW0=')).to.eql('bG9yZW0gaXBzdW0=');
expect(removePotentialDataAndMimePrefix('data:image/jpeg;base64,lorem')).to.eql('lorem');
expect(removePotentialDataAndMimePrefix('data:image/jpeg;notbase64,lorem')).to.eql(
'data:image/jpeg;notbase64,lorem',
);
expect(removePotentialDataAndMimePrefix('data:unknownmime;base64,lorem')).to.eql('lorem');
});
});
});

33
src/utils/base64.ts Normal file
View File

@@ -0,0 +1,33 @@
export { textToBase64, base64ToText, isValidBase64, removePotentialDataAndMimePrefix };
function textToBase64(str: string) {
return window.btoa(str);
}
function base64ToText(str: string) {
if (!isValidBase64(str)) {
throw new Error('Incorrect base64 string');
}
const cleanStr = removePotentialDataAndMimePrefix(str);
try {
return window.atob(cleanStr);
} catch (_) {
throw new Error('Incorrect base64 string');
}
}
function removePotentialDataAndMimePrefix(str: string) {
return str.replace(/^data:.*?;base64,/, '');
}
function isValidBase64(str: string) {
const cleanStr = removePotentialDataAndMimePrefix(str);
try {
return window.btoa(window.atob(cleanStr)) === cleanStr;
} catch (err) {
return false;
}
}

15
src/utils/boolean.test.ts Normal file
View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { isNotThrowing } from './boolean';
describe('boolean utils', () => {
describe('isNotThrowing', () => {
it('should return if the call throws or false otherwise', () => {
expect(isNotThrowing(() => {})).to.eql(true);
expect(
isNotThrowing(() => {
throw new Error();
}),
).to.eql(false);
});
});
});

10
src/utils/boolean.ts Normal file
View File

@@ -0,0 +1,10 @@
export { isNotThrowing };
function isNotThrowing(cb: () => unknown): boolean {
try {
cb();
return true;
} catch (_) {
return false;
}
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { withDefaultOnError } from './defaults';
describe('defaults util', () => {
describe('withDefaultOnError', () => {
it('should return the callback or the default one if the callback throws', () => {
expect(withDefaultOnError(() => 'original', 'default')).to.eql('original');
});
expect(
withDefaultOnError(() => {
throw '';
}, 'default'),
).to.eql('default');
});
});

9
src/utils/defaults.ts Normal file
View File

@@ -0,0 +1,9 @@
export { withDefaultOnError };
function withDefaultOnError<A, B>(cb: () => A, defaultValue: B): A | B {
try {
return cb();
} catch (_) {
return defaultValue;
}
}