Compare commits

...

11 Commits

Author SHA1 Message Date
Corentin Thomasset
4d5a67d96d chore(version): release 2023.11.01-e164afb 2023-11-01 10:02:22 +01:00
Corentin Thomasset
8174db9cf3 docs(changelog): update changelog for 2023.11.01-e164afb 2023-11-01 10:02:22 +01:00
Corentin THOMASSET
e164afb664 chore(deps): clean unused dependencies (#709)
* chore(deps): removed unused dep randombytes

* chore(deps): removed unused dep c8

* chore(deps): updated dep naive-ui
2023-10-31 21:00:23 +00:00
Corentin THOMASSET
d0136962b9 feat(command-palette): clear prompt on palette close (#708) 2023-10-31 19:27:21 +00:00
Corentin THOMASSET
015c673e09 refactor(ui): better ui demo preview menu (#664) 2023-10-31 17:49:36 +00:00
Corentin Thomasset
99b1eb944d feat(command-palette): added about page in command palette 2023-10-31 18:26:16 +01:00
Istvan Olah
cc3425dc77 feat(new tool): random MAC address generator (#657)
* #521 Random MAC address generator

* refactor(mac-address-generator): improved ux

* refactor(mac-address-generator): improved ux

---------

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2023-10-31 11:34:24 +00:00
Corentin THOMASSET
681f7bf644 feat(case-converter): added mocking case (#705) 2023-10-31 09:52:01 +00:00
Corentin THOMASSET
f5eb7a8c49 feat(date-converter): added excel date time format (#704) 2023-10-31 08:59:35 +00:00
Corentin THOMASSET
abb8335041 refactor(color-converter): improved color-converter UX (#701) 2023-10-31 07:32:44 +00:00
Corentin THOMASSET
020e9cbe41 refactor(docker): improved docker config (#700) 2023-10-30 17:11:16 +00:00
27 changed files with 1486 additions and 940 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
playwright-report
coverage
dist
test-results

View File

@@ -2,6 +2,65 @@
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.
## Version 2023.11.01-e164afb
### Features
- **command-palette**: clear prompt on palette close (#708) (d013696)
- **command-palette**: added about page in command palette (99b1eb9)
- **new tool**: random MAC address generator (#657) (cc3425d)
- **case-converter**: added mocking case (#705) (681f7bf)
- **date-converter**: added excel date time format (#704) (f5eb7a8)
- **i18n**: token generator (#688) (02e68d3)
- **i18n**: home page (#687) (00562ed)
- **i18n**: support for i18n in .ts files (#683) (ebb4ec4)
- **i18n**: tool card (#682) (84a4a64)
- **i18n**: about page (#680) (a2b53c2)
- **i18n**: 404 page (#679) (35563b8)
- **new tool**: text to ascii converter (#669) (b2ad4f7)
- **new tool**: ULID generator (#623) (5c4d775)
- **new tool**: add wifi qr code generator (#599) (0eedce6)
- **new tool**: iban validation and parser (#591) (3a63837)
- **new tool**: text diff and comparator (#588) (81bfe57)
### Bug fixes
- **deps**: fix issue on slugify (#593) (#673) (720201a)
- **deps**: update dependency monaco-editor to ^0.43.0 (#620) (e371ef7)
- **deps**: update dependency sql-formatter to v13 (#606) (c7d4562)
### Refactoring
- **ui**: better ui demo preview menu (#664) (015c673)
- **color-converter**: improved color-converter UX (#701) (abb8335)
- **docker**: improved docker config (#700) (020e9cb)
- **c-table**: added description on c-table for accessibility (b408df8)
- **ci**: reduced timeout in e2e (#666) (88b8818)
- **ui**: new c-table ui component (#665) (ee4c853)
- **ui**: removed n-page-header component in user-agent parser (#663) (cbf58fd)
- **ui**: removed n-p components in about page (#662) (a757a51)
- **ui**: switched naive tooltip components to custom ones (#661) (025f556)
- **spelling**: minor corrections to phrasing/spelling (#596) (8a30b6b)
- **i18n**: merge tools scoped locales with global ones (#612) (233d556)
- **c-key-value-list**: got rid of table for layout (#611) (7ab9204)
- **CI**: run e2e against built app and no longer vercel (#610) (18dd140)
- **bcrypt**: fix typo (#604) (e18bae1)
### Chores
- **deps**: clean unused dependencies (#709) (e164afb)
- **deps**: update docker/setup-qemu-action action to v3 (#627) (4365226)
- **deps**: update docker/setup-buildx-action action to v3 (#626) (57ecda1)
- **deps**: update docker/login-action action to v3 (#625) (d8d7a3b)
- **deps**: update docker/build-push-action action to v5 (#624) (d36b18f)
- **deps**: update dependency node to v18.18.2 (#674) (eea9f91)
- **deps**: update dependency node to v18.18.0 (#636) (2d2dffb)
- **deps**: update actions/checkout action to v4 (#613) (4972159)
- **deps**: update dependency unplugin-icons to ^0.17.0 (#609) (f035f48)
- **deps**: update dependency @intlify/unplugin-vue-i18n to ^0.13.0 (#597) (d1dff42)
- **deps**: update dependency @antfu/eslint-config to ^0.41.0 (#585) (a9cd91c)
- **deps**: update dependency typescript to ~5.2.0 (#587) (f3e14fc)
### Doc
- **readme**: added contributors list (#622) (557b304)
- **hosting**: added cloudron in the other hosting solutions section (#589) (06c3547)
## Version 2023.08.21-6f93cba
### Features

View File

@@ -1,13 +1,16 @@
# build stage
FROM node:lts-alpine AS build-stage
# Set environment variables for non-interactive npm installs
ENV NPM_CONFIG_LOGLEVEL warn
ENV CI true
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm i --frozen-lockfile
COPY . .
RUN npm install -g pnpm
RUN pnpm i --frozen-lockfile
RUN pnpm build
# production stage
FROM nginx:stable-alpine AS production-stage
FROM nginxinc/nginx-unprivileged:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

6
components.d.ts vendored
View File

@@ -92,7 +92,6 @@ declare module '@vue/runtime-core' {
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiFavoriteFilled: typeof import('~icons/mdi/favorite-filled')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default']
@@ -116,6 +115,7 @@ declare module '@vue/runtime-core' {
KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default']
ListConverter: typeof import('./src/tools/list-converter/list-converter.vue')['default']
LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default']
MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default']
MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']
MathEvaluator: typeof import('./src/tools/math-evaluator/math-evaluator.vue')['default']
MenuBar: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar.vue')['default']
@@ -150,8 +150,6 @@ declare module '@vue/runtime-core' {
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NP: typeof import('naive-ui')['NP']
NPageHeader: typeof import('naive-ui')['NPageHeader']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
@@ -159,7 +157,6 @@ declare module '@vue/runtime-core' {
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
@@ -191,7 +188,6 @@ declare module '@vue/runtime-core' {
'Tool.layout': typeof import('./src/layouts/tool.layout.vue')['default']
ToolCard: typeof import('./src/components/ToolCard.vue')['default']
UlidGenerator: typeof import('./src/tools/ulid-generator/ulid-generator.vue')['default']
Unnamed: typeof import('./src/ui/unnamed/unnamed.vue')['default']
UrlEncoder: typeof import('./src/tools/url-encoder/url-encoder.vue')['default']
UrlParser: typeof import('./src/tools/url-parser/url-parser.vue')['default']
UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default']

View File

@@ -1,6 +1,6 @@
{
"name": "it-tools",
"version": "2023.8.21-6f93cba",
"version": "2023.11.1-e164afb",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@@ -38,9 +38,9 @@
"@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0",
"@sindresorhus/slugify": "^2.2.1",
"@tiptap/pm": "^2.1.6",
"@tiptap/starter-kit": "^2.1.6",
"@tiptap/vue-3": "^2.0.3",
"@tiptap/pm": "2.1.6",
"@tiptap/starter-kit": "2.1.6",
"@tiptap/vue-3": "2.0.3",
"@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.3.0",
@@ -68,14 +68,13 @@
"mathjs": "^11.9.1",
"mime-types": "^2.1.35",
"monaco-editor": "^0.43.0",
"naive-ui": "^2.34.3",
"naive-ui": "^2.35.0",
"netmask": "^2.0.2",
"node-forge": "^1.3.1",
"oui": "^12.0.52",
"pinia": "^2.0.34",
"plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^13.0.0",
"ua-parser-js": "^1.0.35",
"ulid": "^2.3.0",
@@ -105,7 +104,6 @@
"@types/node": "^18.15.11",
"@types/node-forge": "^1.3.2",
"@types/qrcode": "^1.5.0",
"@types/randombytes": "^2.0.0",
"@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.0",
"@unocss/eslint-config": "^0.55.0",
@@ -115,7 +113,6 @@
"@vue/runtime-dom": "^3.3.4",
"@vue/test-utils": "^2.3.2",
"@vue/tsconfig": "^0.4.0",
"c8": "^8.0.0",
"consola": "^3.0.2",
"eslint": "^8.47.0",
"hygen": "^6.2.11",

1793
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import SunIcon from '~icons/mdi/white-balance-sunny';
import GithubIcon from '~icons/mdi/github';
import BugIcon from '~icons/mdi/bug-outline';
import DiceIcon from '~icons/mdi/dice-5';
import InfoIcon from '~icons/mdi/information-outline';
export const useCommandPaletteStore = defineStore('command-palette', () => {
const toolStore = useToolStore();
@@ -61,6 +62,14 @@ export const useCommandPaletteStore = defineStore('command-palette', () => {
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
icon: BugIcon,
},
{
name: 'About',
description: 'Learn more about IT-Tools.',
to: '/about',
category: 'Pages',
keywords: ['about', 'learn', 'more', 'info', 'information'],
icon: InfoIcon,
},
];
const { searchResult } = useFuzzySearch({

View File

@@ -37,6 +37,7 @@ function open() {
function close() {
isModalOpen.value = false;
searchPrompt.value = '';
}
const selectedOptionIndex = ref(0);

View File

@@ -73,6 +73,13 @@ const formats = computed(() => [
label: 'Snakecase:',
value: snakeCase(input.value, baseConfig),
},
{
label: 'Mockingcase:',
value: noCase(input.value, baseConfig)
.split('')
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
.join(''),
},
]);
const inputLabelAlignmentConfig = {

View File

@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Color converter', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/color-converter');
});
test('Has title', async ({ page }) => {
await expect(page).toHaveTitle('Color converter - IT Tools');
});
test('Color is converted from its name to other formats', async ({ page }) => {
await page.getByTestId('input-name').fill('olive');
expect(await page.getByTestId('input-name').inputValue()).toEqual('olive');
expect(await page.getByTestId('input-hex').inputValue()).toEqual('#808000');
expect(await page.getByTestId('input-rgb').inputValue()).toEqual('rgb(128, 128, 0)');
expect(await page.getByTestId('input-hsl').inputValue()).toEqual('hsl(60, 100%, 25%)');
expect(await page.getByTestId('input-hwb').inputValue()).toEqual('hwb(60 0% 50%)');
expect(await page.getByTestId('input-cmyk').inputValue()).toEqual('device-cmyk(0% 0% 100% 50%)');
expect(await page.getByTestId('input-lch').inputValue()).toEqual('lch(52.15% 56.81 99.57)');
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { removeAlphaChannelWhenOpaque } from './color-converter.models';
describe('color-converter models', () => {
describe('removeAlphaChannelWhenOpaque', () => {
it('remove alpha channel of an hex color when it is opaque (alpha = 1)', () => {
expect(removeAlphaChannelWhenOpaque('#000000ff')).toBe('#000000');
expect(removeAlphaChannelWhenOpaque('#ffffffFF')).toBe('#ffffff');
expect(removeAlphaChannelWhenOpaque('#000000FE')).toBe('#000000FE');
expect(removeAlphaChannelWhenOpaque('#00000000')).toBe('#00000000');
});
});
});

View File

@@ -0,0 +1,52 @@
import { type Colord, colord } from 'colord';
import { withDefaultOnError } from '@/utils/defaults';
import { useValidation } from '@/composable/validation';
export { removeAlphaChannelWhenOpaque, buildColorFormat };
function removeAlphaChannelWhenOpaque(hexColor: string) {
return hexColor.replace(/^(#(?:[0-9a-f]{3}){1,2})ff$/i, '$1');
}
function buildColorFormat({
label,
parse = value => colord(value),
format,
placeholder,
invalidMessage = `Invalid ${label.toLowerCase()} format.`,
type = 'text',
}: {
label: string
parse?: (value: string) => Colord
format: (value: Colord) => string
placeholder?: string
invalidMessage?: string
type?: 'text' | 'color-picker'
}) {
const value = ref('');
return {
type,
label,
parse: (v: string) => withDefaultOnError(() => parse(v), undefined),
format,
placeholder,
value,
validation: useValidation({
source: value,
rules: [
{
message: invalidMessage,
validator: v => withDefaultOnError(() => {
if (v === '') {
return true;
}
return parse(v).isValid();
}, false),
},
],
}),
};
}

View File

@@ -1,87 +1,103 @@
<script setup lang="ts">
import type { Colord } from 'colord';
import { colord, extend } from 'colord';
import _ from 'lodash';
import cmykPlugin from 'colord/plugins/cmyk';
import hwbPlugin from 'colord/plugins/hwb';
import namesPlugin from 'colord/plugins/names';
import lchPlugin from 'colord/plugins/lch';
import InputCopyable from '../../components/InputCopyable.vue';
import { buildColorFormat } from './color-converter.models';
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);
const name = ref('');
const hex = ref('#1ea54cff');
const rgb = ref('');
const hsl = ref('');
const hwb = ref('');
const cmyk = ref('');
const lch = ref('');
const formats = {
picker: buildColorFormat({
label: 'color picker',
format: (v: Colord) => v.toHex(),
type: 'color-picker',
}),
hex: buildColorFormat({
label: 'hex',
format: (v: Colord) => v.toHex(),
placeholder: 'e.g. #ff0000',
}),
rgb: buildColorFormat({
label: 'rgb',
format: (v: Colord) => v.toRgbString(),
placeholder: 'e.g. rgb(255, 0, 0)',
}),
hsl: buildColorFormat({
label: 'hsl',
format: (v: Colord) => v.toHslString(),
placeholder: 'e.g. hsl(0, 100%, 50%)',
}),
hwb: buildColorFormat({
label: 'hwb',
format: (v: Colord) => v.toHwbString(),
placeholder: 'e.g. hwb(0, 0%, 0%)',
}),
lch: buildColorFormat({
label: 'lch',
format: (v: Colord) => v.toLchString(),
placeholder: 'e.g. lch(53.24, 104.55, 40.85)',
}),
cmyk: buildColorFormat({
label: 'cmyk',
format: (v: Colord) => v.toCmykString(),
placeholder: 'e.g. cmyk(0, 100%, 100%, 0)',
}),
name: buildColorFormat({
label: 'name',
format: (v: Colord) => v.toName({ closest: true }) ?? 'Unknown',
placeholder: 'e.g. red',
}),
};
function onInputUpdated(value: string, omit: string) {
try {
const color = colord(value);
updateColorValue(colord('#1ea54c'));
if (omit !== 'name') {
name.value = color.toName({ closest: true }) ?? '';
}
if (omit !== 'hex') {
hex.value = color.toHex();
}
if (omit !== 'rgb') {
rgb.value = color.toRgbString();
}
if (omit !== 'hsl') {
hsl.value = color.toHslString();
}
if (omit !== 'hwb') {
hwb.value = color.toHwbString();
}
if (omit !== 'cmyk') {
cmyk.value = color.toCmykString();
}
if (omit !== 'lch') {
lch.value = color.toLchString();
}
function updateColorValue(value: Colord | undefined, omitLabel?: string) {
if (value === undefined) {
return;
}
catch {
//
if (!value.isValid()) {
return;
}
_.forEach(formats, ({ value: valueRef, format }, key) => {
if (key !== omitLabel) {
valueRef.value = format(value);
}
});
}
onInputUpdated(hex.value, 'hex');
</script>
<template>
<c-card>
<n-form label-width="100" label-placement="left">
<n-form-item label="color picker:">
<template v-for="({ label, parse, placeholder, validation, type }, key) in formats" :key="key">
<input-copyable
v-if="type === 'text'"
v-model:value="formats[key].value.value"
:test-id="`input-${key}`"
:label="`${label}:`"
label-position="left"
label-width="100px"
label-align="right"
:placeholder="placeholder"
:validation="validation"
raw-text
clearable
mt-2
@update:value="(v:string) => updateColorValue(parse(v), key)"
/>
<n-form-item v-else-if="type === 'color-picker'" :label="`${label}:`" label-width="100" label-placement="left" :show-feedback="false">
<n-color-picker
v-model:value="hex"
v-model:value="formats[key].value.value"
placement="bottom-end"
@update:value="(v: string) => onInputUpdated(v, 'hex')"
@update:value="(v:string) => updateColorValue(parse(v), key)"
/>
</n-form-item>
<n-form-item label="color name:">
<InputCopyable v-model:value="name" @update:value="(v: string) => onInputUpdated(v, 'name')" />
</n-form-item>
<n-form-item label="hex:">
<InputCopyable v-model:value="hex" @update:value="(v: string) => onInputUpdated(v, 'hex')" />
</n-form-item>
<n-form-item label="rgb:">
<InputCopyable v-model:value="rgb" @update:value="(v: string) => onInputUpdated(v, 'rgb')" />
</n-form-item>
<n-form-item label="hsl:">
<InputCopyable v-model:value="hsl" @update:value="(v: string) => onInputUpdated(v, 'hsl')" />
</n-form-item>
<n-form-item label="hwb:">
<InputCopyable v-model:value="hwb" @update:value="(v: string) => onInputUpdated(v, 'hwb')" />
</n-form-item>
<n-form-item label="lch:">
<InputCopyable v-model:value="lch" @update:value="(v: string) => onInputUpdated(v, 'lch')" />
</n-form-item>
<n-form-item label="cmyk:">
<InputCopyable v-model:value="cmyk" @update:value="(v: string) => onInputUpdated(v, 'cmyk')" />
</n-form-item>
</n-form>
</template>
</c-card>
</template>

View File

@@ -29,5 +29,6 @@ test.describe('Date time converter - json to yaml', () => {
expect((await page.getByTestId('Timestamp').inputValue()).trim()).toEqual('1681333824000');
expect((await page.getByTestId('UTC format').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT');
expect((await page.getByTestId('Mongo ObjectID').inputValue()).trim()).toEqual('64371e400000000000000000');
expect((await page.getByTestId('Excel date/time').inputValue()).trim()).toEqual('45028.88222222222');
});
});

View File

@@ -1,5 +1,8 @@
import { describe, expect, test } from 'vitest';
import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString,
isISO9075DateString,
isMongoObjectId,
@@ -139,4 +142,39 @@ describe('date-time-converter models', () => {
expect(isMongoObjectId('')).toBe(false);
});
});
describe('isExcelFormat', () => {
test('an Excel format string is a floating number that can be negative', () => {
expect(isExcelFormat('0')).toBe(true);
expect(isExcelFormat('1')).toBe(true);
expect(isExcelFormat('1.1')).toBe(true);
expect(isExcelFormat('-1.1')).toBe(true);
expect(isExcelFormat('-1')).toBe(true);
expect(isExcelFormat('')).toBe(false);
expect(isExcelFormat('foo')).toBe(false);
expect(isExcelFormat('1.1.1')).toBe(false);
});
});
describe('dateToExcelFormat', () => {
test('a date in Excel format is the number of days since 01/01/1900', () => {
expect(dateToExcelFormat(new Date('2016-05-20T00:00:00.000Z'))).toBe('42510');
expect(dateToExcelFormat(new Date('2016-05-20T12:00:00.000Z'))).toBe('42510.5');
expect(dateToExcelFormat(new Date('2023-10-31T09:26:06.421Z'))).toBe('45230.39312987268');
expect(dateToExcelFormat(new Date('1970-01-01T00:00:00.000Z'))).toBe('25569');
expect(dateToExcelFormat(new Date('1800-01-01T00:00:00.000Z'))).toBe('-36522');
});
});
describe('excelFormatToDate', () => {
test('a date in Excel format is the number of days since 01/01/1900', () => {
expect(excelFormatToDate('0')).toEqual(new Date('1899-12-30T00:00:00.000Z'));
expect(excelFormatToDate('1')).toEqual(new Date('1899-12-31T00:00:00.000Z'));
expect(excelFormatToDate('2')).toEqual(new Date('1900-01-01T00:00:00.000Z'));
expect(excelFormatToDate('4242.4242')).toEqual(new Date('1911-08-12T10:10:50.880Z'));
expect(excelFormatToDate('42738.22626859954')).toEqual(new Date('2017-01-03T05:25:49.607Z'));
expect(excelFormatToDate('-1000')).toEqual(new Date('1897-04-04T00:00:00.000Z'));
});
});
});

View File

@@ -9,6 +9,9 @@ export {
isTimestamp,
isUTCDateString,
isMongoObjectId,
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
};
const ISO8601_REGEX
@@ -21,6 +24,8 @@ const RFC3339_REGEX
const RFC7231_REGEX = /^[A-Za-z]{3},\s[0-9]{2}\s[A-Za-z]{3}\s[0-9]{4}\s[0-9]{2}:[0-9]{2}:[0-9]{2}\sGMT$/;
const EXCEL_FORMAT_REGEX = /^-?\d+(\.\d+)?$/;
function createRegexMatcher(regex: RegExp) {
return (date?: string) => !_.isNil(date) && regex.test(date);
}
@@ -33,6 +38,8 @@ const isUnixTimestamp = createRegexMatcher(/^[0-9]{1,10}$/);
const isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/);
const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/);
const isExcelFormat = createRegexMatcher(EXCEL_FORMAT_REGEX);
function isUTCDateString(date?: string) {
if (_.isNil(date)) {
return false;
@@ -45,3 +52,11 @@ function isUTCDateString(date?: string) {
return false;
}
}
function dateToExcelFormat(date: Date) {
return String(((date.getTime()) / (1000 * 60 * 60 * 24)) + 25569);
}
function excelFormatToDate(excelFormat: string | number) {
return new Date((Number(excelFormat) - 25569) * 86400 * 1000);
}

View File

@@ -14,6 +14,9 @@ import {
} from 'date-fns';
import type { DateFormat, ToDateMapper } from './date-time-converter.types';
import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString,
isISO9075DateString,
isMongoObjectId,
@@ -85,6 +88,12 @@ const formats: DateFormat[] = [
toDate: objectId => new Date(Number.parseInt(objectId.substring(0, 8), 16) * 1000),
formatMatcher: date => isMongoObjectId(date),
},
{
name: 'Excel date/time',
fromDate: date => dateToExcelFormat(date),
toDate: excelFormatToDate,
formatMatcher: isExcelFormat,
},
];
const formatIndex = ref(6);

View File

@@ -1,6 +1,7 @@
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 macAddressGenerator } from './mac-address-generator';
import { tool as textToBinary } from './text-to-binary';
import { tool as ulidGenerator } from './ulid-generator';
import { tool as ibanValidatorAndParser } from './iban-validator-and-parser';
@@ -140,7 +141,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, ipv6UlaGenerator],
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
},
{
name: 'Math',

View File

@@ -0,0 +1,12 @@
import { Devices } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'MAC address generator',
path: '/mac-address-generator',
description: 'Enter the quantity and prefix. MAC addresses will be generated in your chosen case (uppercase or lowercase)',
keywords: ['mac', 'address', 'generator', 'random', 'prefix'],
component: () => import('./mac-address-generator.vue'),
icon: Devices,
createdAt: new Date('2023-11-31'),
});

View File

@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - MAC address generator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mac-address-generator');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('MAC address generator - IT Tools');
});
});

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import _ from 'lodash';
import { generateRandomMacAddress } from './mac-adress-generator.models';
import { computedRefreshable } from '@/composable/computedRefreshable';
import { useCopy } from '@/composable/copy';
import { usePartialMacAddressValidation } from '@/utils/macAddress';
const amount = useStorage('mac-address-generator-amount', 1);
const macAddressPrefix = useStorage('mac-address-generator-prefix', '64:16:7F');
const prefixValidation = usePartialMacAddressValidation(macAddressPrefix);
const casesTransformers = [
{ label: 'Uppercase', value: (value: string) => value.toUpperCase() },
{ label: 'Lowercase', value: (value: string) => value.toLowerCase() },
];
const caseTransformer = ref(casesTransformers[0].value);
const separators = [
{
label: ':',
value: ':',
},
{
label: '-',
value: '-',
},
{
label: '.',
value: '.',
},
{
label: 'None',
value: '',
},
];
const separator = useStorage('mac-address-generator-separator', separators[0].value);
const [macAddresses, refreshMacAddresses] = computedRefreshable(() => {
if (!prefixValidation.isValid) {
return '';
}
const ids = _.times(amount.value, () => caseTransformer.value(generateRandomMacAddress({
prefix: macAddressPrefix.value,
separator: separator.value,
})));
return ids.join('\n');
});
const { copy } = useCopy({ source: macAddresses, text: 'MAC addresses copied to the clipboard' });
</script>
<template>
<div flex flex-col justify-center gap-2>
<div flex items-center>
<label w-150px pr-12px text-right> Quantity:</label>
<n-input-number v-model:value="amount" min="1" max="100" flex-1 />
</div>
<c-input-text
v-model:value="macAddressPrefix"
label="MAC address prefix:"
placeholder="Set a prefix, e.g. 64:16:7F"
clearable
label-position="left"
spellcheck="false"
:validation="prefixValidation"
raw-text
label-width="150px"
label-align="right"
/>
<c-buttons-select
v-model:value="caseTransformer"
:options="casesTransformers"
label="Case:"
label-width="150px"
label-align="right"
/>
<c-buttons-select
v-model:value="separator"
:options="separators"
label="Separator:"
label-width="150px"
label-align="right"
/>
<c-card mt-5 flex data-test-id="ulids">
<pre m-0 m-x-auto>{{ macAddresses }}</pre>
</c-card>
<div flex justify-center gap-2>
<c-button data-test-id="refresh" @click="refreshMacAddresses()">
Refresh
</c-button>
<c-button @click="copy()">
Copy
</c-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { generateRandomMacAddress, splitPrefix } from './mac-adress-generator.models';
describe('mac-adress-generator models', () => {
describe('splitPrefix', () => {
it('a mac address prefix is splitted around non hex characters', () => {
expect(splitPrefix('')).toEqual([]);
expect(splitPrefix('01')).toEqual(['01']);
expect(splitPrefix('01:')).toEqual(['01']);
expect(splitPrefix('01:23')).toEqual(['01', '23']);
expect(splitPrefix('01-23')).toEqual(['01', '23']);
});
it('when a prefix contains only hex characters, they are grouped by 2', () => {
expect(splitPrefix('0123')).toEqual(['01', '23']);
expect(splitPrefix('012345')).toEqual(['01', '23', '45']);
expect(splitPrefix('0123456')).toEqual(['01', '23', '45', '06']);
});
});
describe('generateRandomMacAddress', () => {
const createRandomByteGenerator = () => {
let i = 0;
return () => (i++).toString(16).padStart(2, '0');
};
it('generates a random mac address', () => {
expect(generateRandomMacAddress({ getRandomByte: createRandomByteGenerator() })).toBe('00:01:02:03:04:05');
});
it('generates a random mac address with a prefix', () => {
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:aa:00:01:02');
expect(generateRandomMacAddress({ prefix: 'ff:ee:a', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:0a:00:01:02');
});
it('generates a random mac address with a prefix and a different separator', () => {
expect(generateRandomMacAddress({ prefix: 'ff-ee-aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff-ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
});
});
});

View File

@@ -0,0 +1,18 @@
import _ from 'lodash';
export { splitPrefix, generateRandomMacAddress };
function splitPrefix(prefix: string): string[] {
const base = prefix.match(/[^0-9a-f]/i) === null ? prefix.match(/.{1,2}/g) ?? [] : prefix.split(/[^0-9a-f]/i);
return base.filter(Boolean).map(byte => byte.padStart(2, '0'));
}
function generateRandomMacAddress({ prefix: rawPrefix = '', separator = ':', getRandomByte = () => _.random(0, 255).toString(16).padStart(2, '0') }: { prefix?: string; separator?: string; getRandomByte?: () => string } = {}) {
const prefix = splitPrefix(rawPrefix);
const randomBytes = _.times(6 - prefix.length, getRandomByte);
const bytes = [...prefix, ...randomBytes];
return bytes.join(separator);
}

View File

@@ -4,10 +4,8 @@ import { demoRoutes } from './demo.routes';
<template>
<div grid grid-cols-5 gap-2>
<c-card v-for="{ name } of demoRoutes" :key="name" :title="String(name)">
<c-button :to="{ name }">
{{ name }}
</c-button>
</c-card>
<c-button v-for="{ name } of demoRoutes" :key="name" :to="{ name }">
{{ name }}
</c-button>
</div>
</template>

View File

@@ -12,7 +12,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
<h1>c-lib components</h1>
<div flex>
<div w-30 b-r b-gray b-op-10 b-r-solid pr-4>
<div w-200px b-r b-gray b-op-10 b-r-solid pr-4>
<c-button
v-for="{ name } of demoRoutes"
:key="name"
@@ -20,6 +20,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
:to="{ name }"
w-full
important:justify-start
important:text-left
:type="route.name === name ? 'primary' : 'default'"
>
{{ name }}

View File

@@ -1,16 +1,16 @@
import type { RouteRecordRaw } from 'vue-router';
import DemoHome from './demo-home.page.vue';
const demoPages = import.meta.glob('../*/*.demo.vue');
const demoPages = import.meta.glob('../*/*.demo.vue', { eager: true });
export const demoRoutes = Object.keys(demoPages).map((path) => {
const [, , fileName] = path.split('/');
const name = fileName.split('.').shift();
export const demoRoutes = Object.keys(demoPages).map((demoComponentPath) => {
const [, , fileName] = demoComponentPath.split('/');
const demoComponentName = fileName.split('.').shift();
return {
path: name,
name,
component: () => import(/* @vite-ignore */ path),
path: demoComponentName,
name: demoComponentName,
component: () => import(/* @vite-ignore */ demoComponentPath),
} as RouteRecordRaw;
});

View File

@@ -15,4 +15,18 @@ function macAddressValidation(value: Ref) {
});
}
export { macAddressValidation, macAddressValidationRules };
const partialMacAddressValidationRules = [
{
message: 'Invalid partial MAC address',
validator: (value: string) => value.trim().match(/^([0-9a-f]{2}[:\-. ]){0,5}([0-9a-f]{0,2})$/i),
},
];
function usePartialMacAddressValidation(value: Ref) {
return useValidation({
source: value,
rules: partialMacAddressValidationRules,
});
}
export { macAddressValidation, macAddressValidationRules, usePartialMacAddressValidation, partialMacAddressValidationRules };