Merge branch 'dev' of https://github.com/zxysilent/it-tools into dev
This commit is contained in:
		
						commit
						a0f2480aee
					
				
							
								
								
									
										18
									
								
								.github/workflows/docker-nightly-release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								.github/workflows/docker-nightly-release.yml
									
									
									
									
										vendored
									
									
								
							| @ -6,8 +6,26 @@ on: | ||||
|     - cron: '0 0 * * *' | ||||
| 
 | ||||
| jobs: | ||||
|   check_date: | ||||
|     runs-on: ubuntu-latest | ||||
|     name: Check latest commit | ||||
|     outputs: | ||||
|       should_run: ${{ steps.should_run.outputs.should_run }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: print latest_commit | ||||
|         run: echo ${{ github.sha }} | ||||
| 
 | ||||
|       - id: should_run | ||||
|         continue-on-error: true | ||||
|         name: check latest commit is less than a day | ||||
|         if: ${{ github.event_name == 'schedule' }} | ||||
|         run: test -z $(git rev-list  --after="24 hours"  ${{ github.sha }}) && echo "::set-output name=should_run::false" | ||||
| 
 | ||||
|   ci: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: check_date | ||||
|     if: ${{ needs.check_date.outputs.should_run != 'false' }} | ||||
| 
 | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
							
								
								
									
										23
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| name: E2E tests | ||||
| on: | ||||
|   pull_request: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
| jobs: | ||||
|   test: | ||||
|     timeout-minutes: 60 | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - run: corepack enable | ||||
|       - uses: actions/setup-node@v3 | ||||
|         with: | ||||
|           node-version: 16 | ||||
|           cache: 'pnpm' | ||||
|       - name: Install dependencies | ||||
|         run: pnpm install | ||||
|       - name: Install Playwright Browsers | ||||
|         run: pnpm exec playwright install --with-deps | ||||
|       - name: Run Playwright tests | ||||
|         run: pnpm exec playwright test | ||||
							
								
								
									
										48
									
								
								.github/workflows/releases.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/releases.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| name: Release new versions | ||||
| 
 | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - 'v*.*.*' | ||||
| 
 | ||||
| jobs: | ||||
|   docker-release: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Get release version | ||||
|         run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV | ||||
| 
 | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
| 
 | ||||
|       - name: Login to GitHub Container Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.repository_owner }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
| 
 | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
| 
 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
| 
 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
| 
 | ||||
|       - name: Build and push | ||||
|         uses: docker/build-push-action@v4 | ||||
|         with: | ||||
|           context: . | ||||
|           file: ./Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           push: true | ||||
|           tags: | | ||||
|             corentinth/it-tools:latest | ||||
|             corentinth/it-tools:${{ env.RELEASE_VERSION }} | ||||
|             ghcr.io/corentinth/it-tools:latest | ||||
|             ghcr.io/corentinth/it-tools:${{ env.RELEASE_VERSION}} | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -27,4 +27,7 @@ coverage | ||||
| *.sln | ||||
| *.sw? | ||||
| 
 | ||||
| .env | ||||
| .env | ||||
| /test-results/ | ||||
| /playwright-report/ | ||||
| /playwright/.cache/ | ||||
|  | ||||
							
								
								
									
										21
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								README.md
									
									
									
									
									
								
							| @ -10,16 +10,25 @@ You have an idea of a tool? Submit a [feature request](https://github.com/Corent | ||||
| 
 | ||||
| ## Self host | ||||
| 
 | ||||
| Self host your own version of it-tools in your homelab with docker: | ||||
| Self host solutions for your homelab | ||||
| 
 | ||||
| **From docker hub:** | ||||
| 
 | ||||
| ```sh | ||||
| docker run -d \ | ||||
|   --name it-tools \ | ||||
|   --restart unless-stopped \ | ||||
|   -p 8080:80 \ | ||||
|   corentinth/it-tools:latest | ||||
| docker run -d --name it-tools --restart unless-stopped -p 8080:80 corentinth/it-tools:latest | ||||
| ``` | ||||
| 
 | ||||
| **From github packages:** | ||||
| 
 | ||||
| ```sh | ||||
| docker run -d --name it-tools --restart unless-stopped -p 8080:80 ghcr.io/corentinth/it-tools:latest | ||||
| ``` | ||||
| 
 | ||||
| **Other solutions:** | ||||
| 
 | ||||
| - [Tipi](https://www.runtipi.io/docs/apps-available) | ||||
| - [Unraid](https://unraid.net/community/apps?q=it-tools) | ||||
| 
 | ||||
| ## Contribute | ||||
| 
 | ||||
| ### Recommended IDE Setup | ||||
|  | ||||
							
								
								
									
										1
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -12,6 +12,7 @@ declare module '@vue/runtime-core' { | ||||
|     CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default'] | ||||
|     ColoredCard: typeof import('./src/components/ColoredCard.vue')['default'] | ||||
|     FavoriteButton: typeof import('./src/components/FavoriteButton.vue')['default'] | ||||
|     FormatTransformer: typeof import('./src/components/FormatTransformer.vue')['default'] | ||||
|     InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] | ||||
|     MenuIconItem: typeof import('./src/components/MenuIconItem.vue')['default'] | ||||
|     MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] | ||||
|  | ||||
							
								
								
									
										27
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								package.json
									
									
									
									
									
								
							| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "it-tools", | ||||
|   "version": "2.19.0", | ||||
|   "version": "2023.4.13-dce9ff9", | ||||
|   "description": "Collection of handy online tools for developers, with great UX. ", | ||||
|   "keywords": [ | ||||
|     "productivity", | ||||
| @ -25,6 +25,7 @@ | ||||
|     "preview": "vite preview --port 5050", | ||||
|     "test": "npm run test:unit", | ||||
|     "test:unit": "vitest --environment jsdom", | ||||
|     "test:e2e": "playwright test", | ||||
|     "coverage": "vitest run --coverage", | ||||
|     "typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", | ||||
|     "lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore", | ||||
| @ -48,7 +49,7 @@ | ||||
|     "colord": "^2.9.3", | ||||
|     "composerize-ts": "^0.6.2", | ||||
|     "cron-validator": "^1.3.1", | ||||
|     "cronstrue": "^2.24.0", | ||||
|     "cronstrue": "^2.26.0", | ||||
|     "crypto-js": "^4.1.1", | ||||
|     "date-fns": "^2.29.3", | ||||
|     "figue": "^1.2.0", | ||||
| @ -62,8 +63,8 @@ | ||||
|     "naive-ui": "^2.34.3", | ||||
|     "netmask": "^2.0.2", | ||||
|     "node-forge": "^1.3.1", | ||||
|     "oui": "^12.0.51", | ||||
|     "pinia": "^2.0.33", | ||||
|     "oui": "^12.0.52", | ||||
|     "pinia": "^2.0.34", | ||||
|     "plausible-tracker": "^0.3.8", | ||||
|     "qrcode": "^1.5.1", | ||||
|     "randombytes": "^2.1.0", | ||||
| @ -72,9 +73,11 @@ | ||||
|     "ua-parser-js": "^1.0.35", | ||||
|     "uuid": "^8.3.2", | ||||
|     "vue": "^3.2.47", | ||||
|     "vue-router": "^4.1.6" | ||||
|     "vue-router": "^4.1.6", | ||||
|     "yaml": "^2.2.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@playwright/test": "^1.32.3", | ||||
|     "@rushstack/eslint-patch": "^1.2.0", | ||||
|     "@types/bcryptjs": "^2.4.2", | ||||
|     "@types/crypto-js": "^4.1.1", | ||||
| @ -82,15 +85,15 @@ | ||||
|     "@types/lodash": "^4.14.192", | ||||
|     "@types/mime-types": "^2.1.1", | ||||
|     "@types/netmask": "^2.0.0", | ||||
|     "@types/node": "^16.18.22", | ||||
|     "@types/node-forge": "^1.3.1", | ||||
|     "@types/node": "^16.18.23", | ||||
|     "@types/node-forge": "^1.3.2", | ||||
|     "@types/prettier": "^2.7.2", | ||||
|     "@types/qrcode": "^1.5.0", | ||||
|     "@types/randombytes": "^2.0.0", | ||||
|     "@types/ua-parser-js": "^0.7.36", | ||||
|     "@types/uuid": "^8.3.4", | ||||
|     "@typescript-eslint/parser": "^5.57.0", | ||||
|     "@unocss/eslint-config": "^0.50.6", | ||||
|     "@typescript-eslint/parser": "^5.58.0", | ||||
|     "@unocss/eslint-config": "^0.50.8", | ||||
|     "@vitejs/plugin-vue": "^2.3.4", | ||||
|     "@vitejs/plugin-vue-jsx": "^1.3.10", | ||||
|     "@vue/eslint-config-prettier": "^7.1.0", | ||||
| @ -98,9 +101,9 @@ | ||||
|     "@vue/test-utils": "^2.3.2", | ||||
|     "@vue/tsconfig": "^0.1.3", | ||||
|     "c8": "^7.13.0", | ||||
|     "eslint": "^8.36.0", | ||||
|     "eslint": "^8.38.0", | ||||
|     "eslint-config-prettier": "^8.8.0", | ||||
|     "eslint-import-resolver-typescript": "^3.5.3", | ||||
|     "eslint-import-resolver-typescript": "^3.5.5", | ||||
|     "eslint-plugin-import": "^2.27.5", | ||||
|     "eslint-plugin-vue": "^8.7.1", | ||||
|     "jsdom": "^19.0.0", | ||||
| @ -109,7 +112,7 @@ | ||||
|     "standard-version": "^9.5.0", | ||||
|     "start-server-and-test": "^1.15.4", | ||||
|     "typescript": "~4.5.5", | ||||
|     "unocss": "^0.50.6", | ||||
|     "unocss": "^0.50.8", | ||||
|     "unplugin-auto-import": "^0.15.2", | ||||
|     "unplugin-vue-components": "^0.24.1", | ||||
|     "vite": "^2.9.15", | ||||
|  | ||||
							
								
								
									
										82
									
								
								playwright.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								playwright.config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | ||||
| import { defineConfig, devices } from '@playwright/test'; | ||||
| 
 | ||||
| /** | ||||
|  * Read environment variables from file. | ||||
|  * https://github.com/motdotla/dotenv
 | ||||
|  */ | ||||
| // require('dotenv').config();
 | ||||
| 
 | ||||
| /** | ||||
|  * See https://playwright.dev/docs/test-configuration.
 | ||||
|  */ | ||||
| export default defineConfig({ | ||||
|   testDir: './src', | ||||
|   testMatch: /.*\.e2e\.(spec\.)?ts/, | ||||
|   /* Run tests in files in parallel */ | ||||
|   fullyParallel: true, | ||||
|   /* Fail the build on CI if you accidentally left test.only in the source code. */ | ||||
|   forbidOnly: !!process.env.CI, | ||||
|   /* Retry on CI only */ | ||||
|   retries: process.env.CI ? 2 : 0, | ||||
|   /* Opt out of parallel tests on CI. */ | ||||
|   workers: process.env.CI ? 1 : undefined, | ||||
|   /* Reporter to use. See https://playwright.dev/docs/test-reporters */ | ||||
|   reporter: 'html', | ||||
|   /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ | ||||
|   use: { | ||||
|     /* Base URL to use in actions like `await page.goto('/')`. */ | ||||
|     baseURL: 'http://127.0.0.1:3000', | ||||
| 
 | ||||
|     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ | ||||
|     trace: 'on-first-retry', | ||||
| 
 | ||||
|     testIdAttribute: 'data-test-id', | ||||
|     locale: 'en-GB', | ||||
|     timezoneId: 'Europe/Paris', | ||||
|   }, | ||||
| 
 | ||||
|   /* Configure projects for major browsers */ | ||||
|   projects: [ | ||||
|     { | ||||
|       name: 'chromium', | ||||
|       use: { ...devices['Desktop Chrome'] }, | ||||
|     }, | ||||
| 
 | ||||
|     { | ||||
|       name: 'firefox', | ||||
|       use: { ...devices['Desktop Firefox'] }, | ||||
|     }, | ||||
| 
 | ||||
|     { | ||||
|       name: 'webkit', | ||||
|       use: { ...devices['Desktop Safari'] }, | ||||
|     }, | ||||
| 
 | ||||
|     /* Test against mobile viewports. */ | ||||
|     // {
 | ||||
|     //   name: 'Mobile Chrome',
 | ||||
|     //   use: { ...devices['Pixel 5'] },
 | ||||
|     // },
 | ||||
|     // {
 | ||||
|     //   name: 'Mobile Safari',
 | ||||
|     //   use: { ...devices['iPhone 12'] },
 | ||||
|     // },
 | ||||
| 
 | ||||
|     /* Test against branded browsers. */ | ||||
|     // {
 | ||||
|     //   name: 'Microsoft Edge',
 | ||||
|     //   use: { ...devices['Desktop Edge'], channel: 'msedge' },
 | ||||
|     // },
 | ||||
|     // {
 | ||||
|     //   name: 'Google Chrome',
 | ||||
|     //   use: { ..devices['Desktop Chrome'], channel: 'chrome' },
 | ||||
|     // },
 | ||||
|   ], | ||||
| 
 | ||||
|   /* Run your local dev server before starting the tests */ | ||||
|   webServer: { | ||||
|     command: 'npm run dev', | ||||
|     url: 'http://127.0.0.1:3000', | ||||
|     reuseExistingServer: !process.env.CI, | ||||
|   }, | ||||
| }); | ||||
							
								
								
									
										665
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										665
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -5,6 +5,7 @@ import { fileURLToPath } from 'url'; | ||||
| const currentDirname = dirname(fileURLToPath(import.meta.url)); | ||||
| 
 | ||||
| const toolsDir = join(currentDirname, '..', 'src', 'tools'); | ||||
| // eslint-disable-next-line no-undef
 | ||||
| const toolName = process.argv[2]; | ||||
| 
 | ||||
| if (!toolName) { | ||||
| @ -73,6 +74,28 @@ import { expect, describe, it } from 'vitest'; | ||||
| `,
 | ||||
| ); | ||||
| 
 | ||||
| createToolFile( | ||||
|   `${toolName}.e2e.spec.ts`, | ||||
|   ` | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - ${toolNameTitleCase}', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/${toolName}'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('${toolNameTitleCase} - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('', async ({ page }) => { | ||||
| 
 | ||||
|   }); | ||||
| }); | ||||
|    | ||||
| `,
 | ||||
| ); | ||||
| 
 | ||||
| const toolsIndex = join(toolsDir, 'index.ts'); | ||||
| const indexContent = await readFile(toolsIndex, { encoding: 'utf-8' }).then((r) => r.split('\n')); | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										57
									
								
								src/components/FormatTransformer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/components/FormatTransformer.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,57 @@ | ||||
| <template> | ||||
|   <n-form-item :label="inputLabel" v-bind="validationAttrs"> | ||||
|     <n-input | ||||
|       ref="inputElement" | ||||
|       v-model:value="input" | ||||
|       :placeholder="inputPlaceholder" | ||||
|       type="textarea" | ||||
|       rows="20" | ||||
|       autocomplete="off" | ||||
|       autocorrect="off" | ||||
|       autocapitalize="off" | ||||
|       spellcheck="false" | ||||
|       :input-props="{ 'data-test-id': 'input' }" | ||||
|     /> | ||||
|   </n-form-item> | ||||
|   <n-form-item :label="outputLabel"> | ||||
|     <textarea-copyable :value="output" :language="outputLanguage" :follow-height-of="inputElement" /> | ||||
|   </n-form-item> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { useValidation, type UseValidationRule } from '@/composable/validation'; | ||||
| import _ from 'lodash'; | ||||
| 
 | ||||
| const props = withDefaults( | ||||
|   defineProps<{ | ||||
|     transformer?: (v: string) => string; | ||||
|     inputValidationRules?: UseValidationRule<string>[]; | ||||
|     inputLabel?: string; | ||||
|     inputPlaceholder?: string; | ||||
|     inputDefault?: string; | ||||
|     outputLabel?: string; | ||||
|     outputLanguage?: string; | ||||
|   }>(), | ||||
|   { | ||||
|     transformer: _.identity, | ||||
|     inputValidationRules: () => [], | ||||
|     inputLabel: 'Input', | ||||
|     inputDefault: '', | ||||
|     inputPlaceholder: 'Input...', | ||||
|     outputLabel: 'Output', | ||||
|     outputLanguage: '', | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| const { transformer, inputValidationRules, inputLabel, outputLabel, outputLanguage, inputPlaceholder, inputDefault } = | ||||
|   toRefs(props); | ||||
| 
 | ||||
| const inputElement = ref(); | ||||
| 
 | ||||
| const input = ref(inputDefault.value); | ||||
| const output = computed(() => transformer.value(input.value)); | ||||
| 
 | ||||
| const { attrs: validationAttrs } = useValidation({ source: input, rules: inputValidationRules.value }); | ||||
| </script> | ||||
| 
 | ||||
| <style scoped></style> | ||||
| @ -7,13 +7,13 @@ | ||||
|         :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-code :code="value" :language="language" :trim="false" data-test-id="area-content" /> | ||||
|         </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-button circle secondary size="large" @click="onCopyClicked"> | ||||
|               <n-icon size="22" :component="Copy" /> | ||||
|             </n-button> | ||||
|           </div> | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { reactive, watch, type Ref } from 'vue'; | ||||
| 
 | ||||
| type ValidatorReturnType = unknown; | ||||
| 
 | ||||
| interface UseValidationRule<T> { | ||||
| export interface UseValidationRule<T> { | ||||
|   validator: (value: T) => ValidatorReturnType; | ||||
|   message: string; | ||||
| } | ||||
| @ -20,12 +20,20 @@ export function isFalsyOrHasThrown(cb: () => ValidatorReturnType): boolean { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type ValidationAttrs = { | ||||
| export 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, | ||||
|   watch: watchRefs = [], | ||||
| }: { | ||||
|   source: Ref<T>; | ||||
|   rules: UseValidationRule<T>[]; | ||||
|   watch?: Ref<unknown>[]; | ||||
| }) { | ||||
|   const state = reactive<{ | ||||
|     message: string; | ||||
|     status: undefined | 'error'; | ||||
| @ -42,7 +50,7 @@ export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: Use | ||||
|   }); | ||||
| 
 | ||||
|   watch( | ||||
|     [source], | ||||
|     [source, ...watchRefs], | ||||
|     () => { | ||||
|       state.message = ''; | ||||
|       state.status = undefined; | ||||
|  | ||||
| @ -53,14 +53,6 @@ export const config = figue({ | ||||
|       default: false, | ||||
|     }, | ||||
|   }, | ||||
|   tools: { | ||||
|     newTools: { | ||||
|       doc: 'Tool names for tools flagged a as new', | ||||
|       format: 'array', | ||||
|       default: [], | ||||
|       env: 'VITE_NEW_TOOLS', | ||||
|     }, | ||||
|   }, | ||||
|   showBanner: { | ||||
|     doc: 'Show the banner', | ||||
|     format: 'boolean', | ||||
|  | ||||
| @ -1,13 +1,12 @@ | ||||
| <script lang="ts" setup> | ||||
| import { NIcon, useThemeVars, type MenuGroupOption, type MenuOption } from 'naive-ui'; | ||||
| import { computed, h } from 'vue'; | ||||
| import { RouterLink, useRoute } from 'vue-router'; | ||||
| import { NIcon, useThemeVars } from 'naive-ui'; | ||||
| import { computed } from 'vue'; | ||||
| import { RouterLink } from 'vue-router'; | ||||
| import { Heart, Menu2, Home2 } from '@vicons/tabler'; | ||||
| import { toolsByCategory } from '@/tools'; | ||||
| import { useStyleStore } from '@/stores/style.store'; | ||||
| import { config } from '@/config'; | ||||
| import MenuIconItem from '@/components/MenuIconItem.vue'; | ||||
| import type { Tool, ToolCategory } from '@/tools/tools.types'; | ||||
| import type { ToolCategory } from '@/tools/tools.types'; | ||||
| import { useToolStore } from '@/tools/tools.store'; | ||||
| import { useTracker } from '@/modules/tracker/tracker.services'; | ||||
| import CollapsibleToolMenu from '@/components/CollapsibleToolMenu.vue'; | ||||
| @ -17,7 +16,6 @@ import MenuLayout from '../components/MenuLayout.vue'; | ||||
| import NavbarButtons from '../components/NavbarButtons.vue'; | ||||
| 
 | ||||
| const themeVars = useThemeVars(); | ||||
| const route = useRoute(); | ||||
| const styleStore = useStyleStore(); | ||||
| const version = config.app.version; | ||||
| const commitSha = config.app.lastCommitSha.slice(0, 7); | ||||
|  | ||||
| @ -1,23 +1,20 @@ | ||||
| <script setup lang="ts"> | ||||
| import { Coffee } from '@vicons/tabler'; | ||||
| import { useHead } from '@vueuse/head'; | ||||
| 
 | ||||
| useHead({ title: 'Page not found - IT Tools' }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div class="e404-wrapper"> | ||||
|     <n-result status="404" title="404 Not Found" description="Sorry, this page does not seem to exist"> | ||||
|       <template #footer> | ||||
|         <router-link to="/" #="{ navigate, href }" custom> | ||||
|           <n-button tag="a" :href="href" secondary type="success" @click="navigate"> Back home </n-button> | ||||
|         </router-link> | ||||
|       </template> | ||||
|     </n-result> | ||||
|   <div mt-20 flex flex-col items-center> | ||||
|     <n-icon :component="Coffee" size="100" depth="3" /> | ||||
| 
 | ||||
|     <n-h1 m-0 mt-3>404 Not Found</n-h1> | ||||
|     <n-text mt-4 block depth="3">Sorry, this page does not seem to exist</n-text> | ||||
|     <n-text mb-8 block depth="3">Maybe the cache is doing tricky things, try force-refreshing?</n-text> | ||||
| 
 | ||||
|     <router-link to="/" #="{ navigate, href }" custom> | ||||
|       <n-button tag="a" :href="href" secondary @click="navigate"> Back home </n-button> | ||||
|     </router-link> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .e404-wrapper { | ||||
|   padding-top: 150px; | ||||
| } | ||||
| </style> | ||||
|  | ||||
| @ -8,4 +8,5 @@ export const tool = defineTool({ | ||||
|   keywords: ['benchmark', 'builder', 'execution', 'duration', 'mean', 'variance'], | ||||
|   component: () => import('./benchmark-builder.vue'), | ||||
|   icon: SpeedFilled, | ||||
|   createdAt: new Date('2023-04-05'), | ||||
| }); | ||||
|  | ||||
| @ -0,0 +1,33 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Date time converter - json to yaml', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/date-converter'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('Date-time converter - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Format is auto detected from a date and the date is correctly converted', async ({ page }) => { | ||||
|     const initialFormat = await page.getByTestId('date-time-converter-format-select').innerText(); | ||||
|     expect(initialFormat.trim()).toEqual('Timestamp'); | ||||
| 
 | ||||
|     await page.getByTestId('date-time-converter-input').fill('2023-04-12T23:10:24+02:00'); | ||||
| 
 | ||||
|     const detectedFormat = await page.getByTestId('date-time-converter-format-select').innerText(); | ||||
|     expect(detectedFormat.trim()).toEqual('ISO 8601'); | ||||
| 
 | ||||
|     expect((await page.getByTestId('JS locale date string').inputValue()).trim()).toEqual( | ||||
|       'Wed Apr 12 2023 23:10:24 GMT+0200 (Central European Summer Time)', | ||||
|     ); | ||||
|     expect((await page.getByTestId('ISO 8601').inputValue()).trim()).toEqual('2023-04-12T23:10:24+02:00'); | ||||
|     expect((await page.getByTestId('ISO 9075').inputValue()).trim()).toEqual('2023-04-12 23:10:24'); | ||||
|     expect((await page.getByTestId('Unix timestamp').inputValue()).trim()).toEqual('1681333824'); | ||||
|     expect((await page.getByTestId('RFC 7231').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT'); | ||||
|     expect((await page.getByTestId('RFC 3339').inputValue()).trim()).toEqual('2023-04-12T23:10:24+02:00'); | ||||
|     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'); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										142
									
								
								src/tools/date-time-converter/date-time-converter.models.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/tools/date-time-converter/date-time-converter.models.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | ||||
| import { describe, test, expect } from 'vitest'; | ||||
| import { | ||||
|   isISO8601DateTimeString, | ||||
|   isISO9075DateString, | ||||
|   isRFC3339DateString, | ||||
|   isRFC7231DateString, | ||||
|   isUnixTimestamp, | ||||
|   isTimestamp, | ||||
|   isUTCDateString, | ||||
|   isMongoObjectId, | ||||
| } from './date-time-converter.models'; | ||||
| 
 | ||||
| describe('date-time-converter models', () => { | ||||
|   describe('isISO8601DateTimeString', () => { | ||||
|     test('should return true for valid ISO 8601 date strings', () => { | ||||
|       expect(isISO8601DateTimeString('2021-01-01T00:00:00.000Z')).toBe(true); | ||||
|       expect(isISO8601DateTimeString('2023-04-12T14:56:00+01:00')).toBe(true); | ||||
|       expect(isISO8601DateTimeString('20230412T145600+0100')).toBe(true); | ||||
|       expect(isISO8601DateTimeString('20230412T145600Z')).toBe(true); | ||||
|       expect(isISO8601DateTimeString('2016-02-01')).toBe(true); | ||||
|       expect(isISO8601DateTimeString('2016')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid ISO 8601 date strings', () => { | ||||
|       expect(isISO8601DateTimeString()).toBe(false); | ||||
|       expect(isISO8601DateTimeString('')).toBe(false); | ||||
|       expect(isISO8601DateTimeString('qsdqsd')).toBe(false); | ||||
|       expect(isISO8601DateTimeString('2016-02-01-')).toBe(false); | ||||
|       expect(isISO8601DateTimeString('2021-01-01T00:00:00.')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isISO9075DateString', () => { | ||||
|     test('should return true for valid ISO 9075 date strings', () => { | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00Z')).toBe(true); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00.123456Z')).toBe(true); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00+01:00')).toBe(true); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00-05:00')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid ISO 9075 date strings', () => { | ||||
|       expect(isISO9075DateString('2022/01/01T12:00:00Z')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00.123456789Z')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00+1:00')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00-05:')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01 12:00:00-05:00:00')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01')).toBe(false); | ||||
|       expect(isISO9075DateString('12:00:00Z')).toBe(false); | ||||
|       expect(isISO9075DateString('2022-01-01T12:00:00Zfoo')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isRFC3339DateString', () => { | ||||
|     test('should return true for valid RFC 3339 date strings', () => { | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00Z')).toBe(true); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00.123456789Z')).toBe(true); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00.123456789+01:00')).toBe(true); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00-05:00')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid RFC 3339 date strings', () => { | ||||
|       expect(isRFC3339DateString('2022/01/01T12:00:00Z')).toBe(false); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00.123456789+1:00')).toBe(false); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00-05:')).toBe(false); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00-05:00:00')).toBe(false); | ||||
|       expect(isRFC3339DateString('2022-01-01')).toBe(false); | ||||
|       expect(isRFC3339DateString('12:00:00Z')).toBe(false); | ||||
|       expect(isRFC3339DateString('2022-01-01T12:00:00Zfoo')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isRFC7231DateString', () => { | ||||
|     test('should return true for valid RFC 7231 date strings', () => { | ||||
|       expect(isRFC7231DateString('Sun, 06 Nov 1994 08:49:37 GMT')).toBe(true); | ||||
|       expect(isRFC7231DateString('Tue, 22 Apr 2014 07:00:00 GMT')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid RFC 7231 date strings', () => { | ||||
|       expect(isRFC7231DateString('06 Nov 1994 08:49:37 GMT')).toBe(false); | ||||
|       expect(isRFC7231DateString('Sun, 06 Nov 94 08:49:37 GMT')).toBe(false); | ||||
|       expect(isRFC7231DateString('Sun, 06 Nov 1994 8:49:37 GMT')).toBe(false); | ||||
|       expect(isRFC7231DateString('Sun, 06 Nov 1994 08:49:37 GMT-0500')).toBe(false); | ||||
|       expect(isRFC7231DateString('Sun, 06 November 1994 08:49:37 GMT')).toBe(false); | ||||
|       expect(isRFC7231DateString('Sunday, 06 Nov 1994 08:49:37 GMT')).toBe(false); | ||||
|       expect(isRFC7231DateString('06 Nov 1994')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isUnixTimestamp', () => { | ||||
|     test('should return true for valid Unix timestamps', () => { | ||||
|       expect(isUnixTimestamp('1649789394')).toBe(true); | ||||
|       expect(isUnixTimestamp('1234567890')).toBe(true); | ||||
|       expect(isUnixTimestamp('0')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid Unix timestamps', () => { | ||||
|       expect(isUnixTimestamp('foo')).toBe(false); | ||||
|       expect(isUnixTimestamp('')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isTimestamp', () => { | ||||
|     test('should return true for valid Unix timestamps in milliseconds', () => { | ||||
|       expect(isTimestamp('1649792026123')).toBe(true); | ||||
|       expect(isTimestamp('1234567890000')).toBe(true); | ||||
|       expect(isTimestamp('0')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid Unix timestamps in milliseconds', () => { | ||||
|       expect(isTimestamp('foo')).toBe(false); | ||||
|       expect(isTimestamp('')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isUTCDateString', () => { | ||||
|     test('should return true for valid UTC date strings', () => { | ||||
|       expect(isUTCDateString('Sun, 06 Nov 1994 08:49:37 GMT')).toBe(true); | ||||
|       expect(isUTCDateString('Tue, 22 Apr 2014 07:00:00 GMT')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid UTC date strings', () => { | ||||
|       expect(isUTCDateString('06 Nov 1994 08:49:37 GMT')).toBe(false); | ||||
|       expect(isUTCDateString('16497920261')).toBe(false); | ||||
|       expect(isUTCDateString('foo')).toBe(false); | ||||
|       expect(isUTCDateString('')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isMongoObjectId', () => { | ||||
|     test('should return true for valid Mongo ObjectIds', () => { | ||||
|       expect(isMongoObjectId('507f1f77bcf86cd799439011')).toBe(true); | ||||
|       expect(isMongoObjectId('507f1f77bcf86cd799439012')).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     test('should return false for invalid Mongo ObjectIds', () => { | ||||
|       expect(isMongoObjectId('507f1f77bcf86cd79943901')).toBe(false); | ||||
|       expect(isMongoObjectId('507f1f77bcf86cd79943901z')).toBe(false); | ||||
|       expect(isMongoObjectId('foo')).toBe(false); | ||||
|       expect(isMongoObjectId('')).toBe(false); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										46
									
								
								src/tools/date-time-converter/date-time-converter.models.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/tools/date-time-converter/date-time-converter.models.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| import _ from 'lodash'; | ||||
| 
 | ||||
| export { | ||||
|   isISO8601DateTimeString, | ||||
|   isISO9075DateString, | ||||
|   isRFC3339DateString, | ||||
|   isRFC7231DateString, | ||||
|   isUnixTimestamp, | ||||
|   isTimestamp, | ||||
|   isUTCDateString, | ||||
|   isMongoObjectId, | ||||
| }; | ||||
| 
 | ||||
| const ISO8601_REGEX = | ||||
|   /^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)?(\17[0-5]\d([.,]\d+)?)?([zZ]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/; | ||||
| const ISO9075_REGEX = | ||||
|   /^([0-9]{4})-([0-9]{2})-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,6})?(([+-])([0-9]{2}):([0-9]{2})|Z)?$/; | ||||
| 
 | ||||
| const RFC3339_REGEX = | ||||
|   /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,9})?(([+-])([0-9]{2}):([0-9]{2})|Z)$/; | ||||
| 
 | ||||
| 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$/; | ||||
| 
 | ||||
| function createRegexMatcher(regex: RegExp) { | ||||
|   return (date?: string) => !_.isNil(date) && regex.test(date); | ||||
| } | ||||
| 
 | ||||
| const isISO8601DateTimeString = createRegexMatcher(ISO8601_REGEX); | ||||
| const isISO9075DateString = createRegexMatcher(ISO9075_REGEX); | ||||
| const isRFC3339DateString = createRegexMatcher(RFC3339_REGEX); | ||||
| const isRFC7231DateString = createRegexMatcher(RFC7231_REGEX); | ||||
| const isUnixTimestamp = createRegexMatcher(/^[0-9]{1,10}$/); | ||||
| const isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/); | ||||
| const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/); | ||||
| 
 | ||||
| function isUTCDateString(date?: string) { | ||||
|   if (_.isNil(date)) { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   try { | ||||
|     return new Date(date).toUTCString() === date; | ||||
|   } catch (_ignored) { | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,8 @@ | ||||
| export type ToDateMapper = (value: string) => Date; | ||||
| 
 | ||||
| export type DateFormat = { | ||||
|   name: string; | ||||
|   fromDate: (date: Date) => string; | ||||
|   toDate: (value: string) => Date; | ||||
|   formatMatcher: (dateString: string) => boolean; | ||||
| }; | ||||
| @ -1,44 +1,38 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <n-card> | ||||
|       <n-space justify="center"> | ||||
|         <n-form-item label="Use current date-time ?" label-placement="left" :show-feedback="false"> | ||||
|           <n-switch v-model:value="useCurrentDate" /> | ||||
|         </n-form-item> | ||||
|       </n-space> | ||||
|       <n-form-item | ||||
|         :feedback="inputInvalid ? 'Invalid date for the current format' : ''" | ||||
|         :validation-status="inputInvalid ? 'error' : undefined" | ||||
|       > | ||||
|         <n-input-group style="flex-grow: 1"> | ||||
|           <n-select | ||||
|             v-model:value="inputFormat" | ||||
|             style="width: 200px" | ||||
|             :options="formats.map(({ name }, i) => ({ label: name, value: i }))" | ||||
|             :disabled="useCurrentDate" | ||||
|           /> | ||||
|     <n-form-item :show-label="false" v-bind="validation.attrs"> | ||||
|       <n-input-group> | ||||
|         <n-input | ||||
|           v-model:value="inputDate" | ||||
|           :on-input="onDateInputChanged" | ||||
|           placeholder="Put you date string here..." | ||||
|           clearable | ||||
|           :input-props="{ 'data-test-id': 'date-time-converter-input' }" | ||||
|         /> | ||||
| 
 | ||||
|           <n-input | ||||
|             v-model:value="inputDate" | ||||
|             :on-input="onDateInputChanged" | ||||
|             :disabled="useCurrentDate" | ||||
|             placeholder="Your date string..." | ||||
|           /> | ||||
|         </n-input-group> | ||||
|       </n-form-item> | ||||
|       <n-divider style="margin-top: 0" /> | ||||
|       <div v-for="{ name, fromDate } in formats" :key="name" style="margin: 5px 0"> | ||||
|         <n-input-group> | ||||
|           <n-input-group-label style="flex: 0 0 170px"> {{ name }}: </n-input-group-label> | ||||
|           <input-copyable :value="fromDate(baseDate)" /> | ||||
|         </n-input-group> | ||||
|       </div> | ||||
|     </n-card> | ||||
|         <n-select | ||||
|           v-model:value="formatIndex" | ||||
|           style="flex: 0 0 170px" | ||||
|           :options="formats.map(({ name }, i) => ({ label: name, value: i }))" | ||||
|           data-test-id="date-time-converter-format-select" | ||||
|         /> | ||||
|       </n-input-group> | ||||
|     </n-form-item> | ||||
|     <n-divider style="margin-top: 0" /> | ||||
|     <div v-for="{ name, fromDate } in formats" :key="name" mt-1> | ||||
|       <n-input-group> | ||||
|         <n-input-group-label style="flex: 0 0 170px"> {{ name }}: </n-input-group-label> | ||||
|         <input-copyable | ||||
|           :value="formatDateUsingFormatter(fromDate, normalizedDate)" | ||||
|           placeholder="Invalid date..." | ||||
|           :input-props="{ 'data-test-id': name }" | ||||
|         /> | ||||
|       </n-input-group> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { useRafFn } from '@vueuse/core'; | ||||
| import { | ||||
|   formatISO, | ||||
|   formatISO9075, | ||||
| @ -47,95 +41,132 @@ import { | ||||
|   fromUnixTime, | ||||
|   getTime, | ||||
|   getUnixTime, | ||||
|   isDate, | ||||
|   parseISO, | ||||
|   parseJSON, | ||||
|   isDate, | ||||
|   isValid, | ||||
| } from 'date-fns'; | ||||
| import { ref } from 'vue'; | ||||
| import InputCopyable from '../../components/InputCopyable.vue'; | ||||
| import { withDefaultOnError } from '@/utils/defaults'; | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import type { DateFormat, ToDateMapper } from './date-time-converter.types'; | ||||
| import { | ||||
|   isISO8601DateTimeString, | ||||
|   isISO9075DateString, | ||||
|   isRFC3339DateString, | ||||
|   isRFC7231DateString, | ||||
|   isTimestamp, | ||||
|   isUTCDateString, | ||||
|   isUnixTimestamp, | ||||
|   isMongoObjectId, | ||||
| } from './date-time-converter.models'; | ||||
| 
 | ||||
| const useCurrentDate = ref(true); | ||||
| const inputDate = ref(''); | ||||
| const inputFormat = ref(6); | ||||
| const inputInvalid = ref(false); | ||||
| const baseDate = ref(new Date()); | ||||
| 
 | ||||
| useRafFn(() => { | ||||
|   if (useCurrentDate.value) { | ||||
|     baseDate.value = new Date(); | ||||
| const toDate: ToDateMapper = (date) => new Date(date); | ||||
| 
 | ||||
| function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date) { | ||||
|   if (!date || !validation.isValid) { | ||||
|     return ''; | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| function onDateInputChanged(value: string) { | ||||
|   const { toDate } = formats[inputFormat.value]; | ||||
|   inputInvalid.value = false; | ||||
| 
 | ||||
|   try { | ||||
|     const formatted: Date | string = toDate(value); | ||||
| 
 | ||||
|     if (!isDate(formatted) || isNaN(formatted.getTime())) { | ||||
|       throw new Error('Invalid date'); | ||||
|     } | ||||
| 
 | ||||
|     baseDate.value = formatted; | ||||
|   } catch (_) { | ||||
|     inputInvalid.value = true; | ||||
|   } | ||||
|   return withDefaultOnError(() => formatter(date), ''); | ||||
| } | ||||
| 
 | ||||
| type Format = { | ||||
|   name: string; | ||||
|   fromDate: (date: Date) => string; | ||||
|   toDate: (value: string) => Date; | ||||
| }; | ||||
| 
 | ||||
| const toDate: Format['toDate'] = (date) => new Date(date); | ||||
| 
 | ||||
| const formats: Format[] = [ | ||||
| const formats: DateFormat[] = [ | ||||
|   { | ||||
|     name: 'JS locale date string', | ||||
|     fromDate: (date) => date.toString(), | ||||
|     toDate, | ||||
|     formatMatcher: () => false, | ||||
|   }, | ||||
|   { | ||||
|     name: 'ISO 8601', | ||||
|     fromDate: formatISO, | ||||
|     toDate: parseISO, | ||||
|     formatMatcher: (date) => isISO8601DateTimeString(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'ISO 9075', | ||||
|     fromDate: formatISO9075, | ||||
|     toDate: parseISO, | ||||
|     formatMatcher: (date) => isISO9075DateString(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'RFC 3339', | ||||
|     fromDate: formatRFC3339, | ||||
|     toDate, | ||||
|     formatMatcher: (date) => isRFC3339DateString(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'RFC 7231', | ||||
|     fromDate: formatRFC7231, | ||||
|     toDate, | ||||
|   }, | ||||
|   { | ||||
|     name: 'Timestamp', | ||||
|     fromDate: (date) => String(getTime(date)), | ||||
|     toDate: (ms) => parseJSON(+ms), | ||||
|     formatMatcher: (date) => isRFC7231DateString(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'Unix timestamp', | ||||
|     fromDate: (date) => String(getUnixTime(date)), | ||||
|     toDate: (sec) => fromUnixTime(+sec), | ||||
|     formatMatcher: (date) => isUnixTimestamp(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'Timestamp', | ||||
|     fromDate: (date) => String(getTime(date)), | ||||
|     toDate: (ms) => parseJSON(+ms), | ||||
|     formatMatcher: (date) => isTimestamp(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'UTC format', | ||||
|     fromDate: (date) => date.toUTCString(), | ||||
|     toDate, | ||||
|     formatMatcher: (date) => isUTCDateString(date), | ||||
|   }, | ||||
|   { | ||||
|     name: 'Mongo ObjectID', | ||||
|     fromDate: (date) => Math.floor(date.getTime() / 1000).toString(16) + '0000000000000000', | ||||
|     toDate: (objectId) => new Date(parseInt(objectId.substring(0, 8), 16) * 1000), | ||||
|     formatMatcher: (date) => isMongoObjectId(date), | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| const formatIndex = ref(6); | ||||
| const now = useNow(); | ||||
| 
 | ||||
| const normalizedDate = computed(() => { | ||||
|   if (!inputDate.value) { | ||||
|     return now.value; | ||||
|   } | ||||
| 
 | ||||
|   const { toDate } = formats[formatIndex.value]; | ||||
| 
 | ||||
|   try { | ||||
|     return toDate(inputDate.value); | ||||
|   } catch (_ignored) { | ||||
|     return undefined; | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| function onDateInputChanged(value: string) { | ||||
|   const matchingIndex = formats.findIndex(({ formatMatcher }) => formatMatcher(value)); | ||||
|   if (matchingIndex !== -1) { | ||||
|     formatIndex.value = matchingIndex; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const validation = useValidation({ | ||||
|   source: inputDate, | ||||
|   watch: [formatIndex], | ||||
|   rules: [ | ||||
|     { | ||||
|       message: 'This date is invalid for this format', | ||||
|       validator: (value) => | ||||
|         withDefaultOnError(() => { | ||||
|           if (value === '') return true; | ||||
| 
 | ||||
|           const maybeDate = formats[formatIndex.value].toDate(value); | ||||
|           return isDate(maybeDate) && isValid(maybeDate); | ||||
|         }, false), | ||||
|     }, | ||||
|   ], | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| @ -1,6 +1,10 @@ | ||||
| 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 yamlToJson } from './yaml-to-json-converter'; | ||||
| import { tool as jsonToYaml } from './json-to-yaml-converter'; | ||||
| import { tool as ipv6UlaGenerator } from './ipv6-ula-generator'; | ||||
| import { tool as ipv4AddressConverter } from './ipv4-address-converter'; | ||||
| import { tool as jsonToGo } from './json-to-go'; | ||||
| import { tool as benchmarkBuilder } from './benchmark-builder'; | ||||
| import { tool as userAgentParser } from './user-agent-parser'; | ||||
| @ -65,6 +69,8 @@ export const toolsByCategory: ToolCategory[] = [ | ||||
|       colorConverter, | ||||
|       caseConverter, | ||||
|       textToNatoAlphabet, | ||||
|       yamlToJson, | ||||
|       jsonToYaml, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
| @ -105,7 +111,7 @@ export const toolsByCategory: ToolCategory[] = [ | ||||
|   }, | ||||
|   { | ||||
|     name: 'Network', | ||||
|     components: [ipv4SubnetCalculator, macAddressLookup], | ||||
|     components: [ipv4SubnetCalculator, ipv4AddressConverter, macAddressLookup, ipv6UlaGenerator], | ||||
|   }, | ||||
|   { | ||||
|     name: 'Math', | ||||
|  | ||||
							
								
								
									
										12
									
								
								src/tools/ipv4-address-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/ipv4-address-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { Binary } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'Ipv4 address converter', | ||||
|   path: '/ipv4-address-converter', | ||||
|   description: 'Convert an ip address into decimal, binary, hexadecimal or event in ipv6', | ||||
|   keywords: ['ipv4', 'address', 'converter', 'decimal', 'hexadecimal', 'binary', 'ipv6'], | ||||
|   component: () => import('./ipv4-address-converter.vue'), | ||||
|   icon: Binary, | ||||
|   createdAt: new Date('2023-04-08'), | ||||
| }); | ||||
| @ -0,0 +1,36 @@ | ||||
| import { expect, describe, it } from 'vitest'; | ||||
| import { isValidIpv4, ipv4ToInt } from './ipv4-address-converter.service'; | ||||
| 
 | ||||
| describe('ipv4-address-converter', () => { | ||||
|   describe('ipv4ToInt', () => { | ||||
|     it('should convert an IPv4 address to an integer', () => { | ||||
|       expect(ipv4ToInt({ ip: '192.168.0.1' })).toBe(3232235521); | ||||
|       expect(ipv4ToInt({ ip: '10.0.0.1' })).toBe(167772161); | ||||
|       expect(ipv4ToInt({ ip: '255.255.255.255' })).toBe(4294967295); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('isValidIpv4', () => { | ||||
|     it('should return true for a valid IP address', () => { | ||||
|       expect(isValidIpv4({ ip: '192.168.0.1' })).to.equal(true); | ||||
|       expect(isValidIpv4({ ip: '10.0.0.1' })).to.equal(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('should return false for an invalid IP address', () => { | ||||
|       expect(isValidIpv4({ ip: '256.168.0.1' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '192.168.0' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '192.168.0.1.2' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '192.168.0.1.' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '.192.168.0.1' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '192.168.0.a' })).to.equal(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('should return false for crap as input', () => { | ||||
|       expect(isValidIpv4({ ip: '' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: ' ' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: 'foo' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '-1' })).to.equal(false); | ||||
|       expect(isValidIpv4({ ip: '0' })).to.equal(false); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -0,0 +1,38 @@ | ||||
| import _ from 'lodash'; | ||||
| 
 | ||||
| export { ipv4ToInt, ipv4ToIpv6, isValidIpv4 }; | ||||
| 
 | ||||
| function ipv4ToInt({ ip }: { ip: string }) { | ||||
|   if (!isValidIpv4({ ip })) { | ||||
|     return 0; | ||||
|   } | ||||
| 
 | ||||
|   return ip | ||||
|     .trim() | ||||
|     .split('.') | ||||
|     .reduce((acc, part, index) => acc + Number(part) * Math.pow(256, 3 - index), 0); | ||||
| } | ||||
| 
 | ||||
| function ipv4ToIpv6({ ip, prefix = '0000:0000:0000:0000:0000:ffff:' }: { ip: string; prefix?: string }) { | ||||
|   if (!isValidIpv4({ ip })) { | ||||
|     return ''; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     prefix + | ||||
|     _.chain(ip) | ||||
|       .trim() | ||||
|       .split('.') | ||||
|       .map((part) => parseInt(part).toString(16).padStart(2, '0')) | ||||
|       .chunk(2) | ||||
|       .map((blocks) => blocks.join('')) | ||||
|       .join(':') | ||||
|       .value() | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function isValidIpv4({ ip }: { ip: string }) { | ||||
|   const cleanIp = ip.trim(); | ||||
| 
 | ||||
|   return /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/.test(cleanIp); | ||||
| } | ||||
							
								
								
									
										64
									
								
								src/tools/ipv4-address-converter/ipv4-address-converter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/tools/ipv4-address-converter/ipv4-address-converter.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,64 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <n-form-item label="An ipv4 address:" v-bind="validationAttrs"> | ||||
|       <n-input v-model:value="rawIpAddress" placeholder="An ipv4 address..." /> | ||||
|     </n-form-item> | ||||
| 
 | ||||
|     <n-divider style="margin-top: 0" mt-0 /> | ||||
| 
 | ||||
|     <n-form-item | ||||
|       v-for="{ label, value } of convertedSections" | ||||
|       :key="label" | ||||
|       :label="label" | ||||
|       label-placement="left" | ||||
|       label-width="100" | ||||
|     > | ||||
|       <input-copyable | ||||
|         :value="validationAttrs.validationStatus === 'error' ? '' : value" | ||||
|         placeholder="Set a correct ipv4 address" | ||||
|       /> | ||||
|     </n-form-item> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import { convertBase } from '../integer-base-converter/integer-base-converter.model'; | ||||
| import { ipv4ToInt, ipv4ToIpv6, isValidIpv4 } from './ipv4-address-converter.service'; | ||||
| 
 | ||||
| const rawIpAddress = useStorage('ipv4-converter:ip', '192.168.1.1'); | ||||
| 
 | ||||
| const convertedSections = computed(() => { | ||||
|   const ipInDecimal = ipv4ToInt({ ip: rawIpAddress.value }); | ||||
| 
 | ||||
|   return [ | ||||
|     { | ||||
|       label: 'Decimal : ', | ||||
|       value: String(ipInDecimal), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Hexadecimal: ', | ||||
|       value: convertBase({ fromBase: 10, toBase: 16, value: String(ipInDecimal) }).toUpperCase(), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Binary: ', | ||||
|       value: convertBase({ fromBase: 10, toBase: 2, value: String(ipInDecimal) }), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Ipv6: ', | ||||
|       value: ipv4ToIpv6({ ip: rawIpAddress.value }), | ||||
|     }, | ||||
|     { | ||||
|       label: 'Ipv6 (short): ', | ||||
|       value: ipv4ToIpv6({ ip: rawIpAddress.value, prefix: '::ffff:' }), | ||||
|     }, | ||||
|   ]; | ||||
| }); | ||||
| 
 | ||||
| const { attrs: validationAttrs } = useValidation({ | ||||
|   source: rawIpAddress, | ||||
|   rules: [{ message: 'Invalid ipv4 address', validator: (ip) => isValidIpv4({ ip }) }], | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped></style> | ||||
							
								
								
									
										12
									
								
								src/tools/ipv6-ula-generator/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/ipv6-ula-generator/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { BuildingFactory } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'IPv6 ULA generator', | ||||
|   path: '/ipv6-ula-generator', | ||||
|   description: 'Generate your own local, non-routable IP addresses on your network according to RFC4193.', | ||||
|   keywords: ['ipv6', 'ula', 'generator', 'rfc4193', 'network', 'private'], | ||||
|   component: () => import('./ipv6-ula-generator.vue'), | ||||
|   icon: BuildingFactory, | ||||
|   createdAt: new Date('2023-04-09'), | ||||
| }); | ||||
							
								
								
									
										65
									
								
								src/tools/ipv6-ula-generator/ipv6-ula-generator.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/tools/ipv6-ula-generator/ipv6-ula-generator.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,65 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <n-space vertical :size="50"> | ||||
|       <n-alert title="Info" type="info"> | ||||
|         This tool uses the first method suggested by IETF using the current timestamp plus the mac address, sha1 hashed, | ||||
|         and the lower 40 bits to generate your random ULA. | ||||
|       </n-alert> | ||||
| 
 | ||||
|       <n-form-item label="MAC address:" v-bind="validationAttrs"> | ||||
|         <n-input | ||||
|           v-model:value="macAddress" | ||||
|           size="large" | ||||
|           placeholder="Type a MAC address" | ||||
|           clearable | ||||
|           autocomplete="off" | ||||
|           autocorrect="off" | ||||
|           autocapitalize="off" | ||||
|           spellcheck="false" | ||||
|         /> | ||||
|       </n-form-item> | ||||
|     </n-space> | ||||
| 
 | ||||
|     <div v-if="validationAttrs.validationStatus !== 'error'"> | ||||
|       <n-input-group v-for="{ label, value } in calculatedSections" :key="label" style="margin: 5px 0"> | ||||
|         <n-input-group-label style="flex: 0 0 160px"> {{ label }} </n-input-group-label> | ||||
|         <input-copyable :value="value" readonly /> | ||||
|       </n-input-group> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { SHA1 } from 'crypto-js'; | ||||
| import InputCopyable from '@/components/InputCopyable.vue'; | ||||
| import { macAddressValidation } from '@/utils/macAddress'; | ||||
| 
 | ||||
| const macAddress = ref('20:37:06:12:34:56'); | ||||
| const calculatedSections = computed(() => { | ||||
|   const timestamp = new Date().getTime(); | ||||
|   const hex40bit = SHA1(timestamp + macAddress.value) | ||||
|     .toString() | ||||
|     .substring(30); | ||||
| 
 | ||||
|   const ula = 'fd' + hex40bit.substring(0, 2) + ':' + hex40bit.substring(2, 6) + ':' + hex40bit.substring(6); | ||||
| 
 | ||||
|   return [ | ||||
|     { | ||||
|       label: 'IPv6 ULA:', | ||||
|       value: `${ula}::/48`, | ||||
|     }, | ||||
|     { | ||||
|       label: 'First routable block:', | ||||
|       value: `${ula}:0::/64`, | ||||
|     }, | ||||
|     { | ||||
|       label: 'Last routable block:', | ||||
|       value: `${ula}:ffff::/64`, | ||||
|     }, | ||||
|   ]; | ||||
| }); | ||||
| 
 | ||||
| const { attrs: validationAttrs } = macAddressValidation(macAddress); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped></style> | ||||
| @ -1,57 +1,27 @@ | ||||
| <template> | ||||
|   <n-form-item | ||||
|     label="Your raw json" | ||||
|     :feedback="rawJsonValidation.message" | ||||
|     :validation-status="rawJsonValidation.status" | ||||
|   > | ||||
|     <n-input | ||||
|       ref="inputElement" | ||||
|       v-model:value="rawJson" | ||||
|       placeholder="Paste your raw json here..." | ||||
|       type="textarea" | ||||
|       rows="20" | ||||
|       autocomplete="off" | ||||
|       autocorrect="off" | ||||
|       autocapitalize="off" | ||||
|       spellcheck="false" | ||||
|     /> | ||||
|   </n-form-item> | ||||
|   <n-form-item label="Minify version of your JSON"> | ||||
|     <textarea-copyable :value="cleanJson" language="json" :follow-height-of="inputElement" /> | ||||
|   </n-form-item> | ||||
|   <format-transformer | ||||
|     input-label="Your raw json" | ||||
|     :input-default="defaultValue" | ||||
|     input-placeholder="Paste your raw json here..." | ||||
|     output-label="Minify version of your JSON" | ||||
|     output-language="json" | ||||
|     :input-validation-rules="rules" | ||||
|     :transformer="transformer" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import TextareaCopyable from '@/components/TextareaCopyable.vue'; | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import type { UseValidationRule } from '@/composable/validation'; | ||||
| import { withDefaultOnError } from '@/utils/defaults'; | ||||
| import JSON5 from 'json5'; | ||||
| import { computed, ref } from 'vue'; | ||||
| 
 | ||||
| const inputElement = ref<HTMLElement>(); | ||||
| const defaultValue = '{\n\t"hello": [\n\t\t"world"\n\t]\n}'; | ||||
| const transformer = (value: string) => withDefaultOnError(() => JSON.stringify(JSON5.parse(value), null, 0), ''); | ||||
| 
 | ||||
| const rawJson = ref('{\n\t"hello": [\n\t\t"world"\n\t]\n}'); | ||||
| const cleanJson = computed(() => withDefaultOnError(() => JSON.stringify(JSON5.parse(rawJson.value), null, 0), '')); | ||||
| 
 | ||||
| const rawJsonValidation = useValidation({ | ||||
|   source: rawJson, | ||||
|   rules: [ | ||||
|     { | ||||
|       validator: (v) => v === '' || JSON5.parse(v), | ||||
|       message: 'Provided JSON is not valid.', | ||||
|     }, | ||||
|   ], | ||||
| }); | ||||
| const rules: UseValidationRule<string>[] = [ | ||||
|   { | ||||
|     validator: (v: string) => v === '' || JSON5.parse(v), | ||||
|     message: 'Provided JSON is not valid.', | ||||
|   }, | ||||
| ]; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped> | ||||
| .result-card { | ||||
|   position: relative; | ||||
| 
 | ||||
|   .copy-button { | ||||
|     position: absolute; | ||||
|     top: 10px; | ||||
|     right: 10px; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  | ||||
							
								
								
									
										12
									
								
								src/tools/json-to-yaml-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/json-to-yaml-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { Braces } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'JSON to YAML converter', | ||||
|   path: '/json-to-yaml-converter', | ||||
|   description: 'Simply convert JSON to YAML with this live online converter.', | ||||
|   keywords: ['yaml', 'to', 'json'], | ||||
|   component: () => import('./json-to-yaml.vue'), | ||||
|   icon: Braces, | ||||
|   createdAt: new Date('2023-04-10'), | ||||
| }); | ||||
							
								
								
									
										19
									
								
								src/tools/json-to-yaml-converter/json-to-yaml.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/tools/json-to-yaml-converter/json-to-yaml.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - json to yaml', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/json-to-yaml-converter'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('JSON to YAML converter - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('json is parsed and output clean yaml', async ({ page }) => { | ||||
|     await page.getByTestId('input').fill('{"foo":"bar","list":["item",{"key":"value"}]}'); | ||||
| 
 | ||||
|     const generatedJson = await page.getByTestId('area-content').innerText(); | ||||
| 
 | ||||
|     expect(generatedJson.trim()).toEqual(`foo: bar\nlist:\n  - item\n  - key: value`.trim()); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										29
									
								
								src/tools/json-to-yaml-converter/json-to-yaml.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/tools/json-to-yaml-converter/json-to-yaml.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| <template> | ||||
|   <format-transformer | ||||
|     input-label="Your JSON" | ||||
|     input-placeholder="Paste your JSON here..." | ||||
|     output-label="YAML from your JSON" | ||||
|     output-language="yaml" | ||||
|     :input-validation-rules="rules" | ||||
|     :transformer="transformer" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import type { UseValidationRule } from '@/composable/validation'; | ||||
| import { isNotThrowing } from '@/utils/boolean'; | ||||
| import { withDefaultOnError } from '@/utils/defaults'; | ||||
| import { stringify } from 'yaml'; | ||||
| import JSON5 from 'json5'; | ||||
| 
 | ||||
| const transformer = (value: string) => withDefaultOnError(() => stringify(JSON5.parse(value)), ''); | ||||
| 
 | ||||
| const rules: UseValidationRule<string>[] = [ | ||||
|   { | ||||
|     validator: (value: string) => value === '' || isNotThrowing(() => stringify(JSON5.parse(value))), | ||||
|     message: 'Provided JSON is not valid.', | ||||
|   }, | ||||
| ]; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped></style> | ||||
| @ -18,7 +18,7 @@ | ||||
| 
 | ||||
|     <br /> | ||||
| 
 | ||||
|     <n-input :value="loremIpsumText" type="textarea" placeholder="Your lorem ipsum..." autosize readonly /> | ||||
|     <n-input :value="loremIpsumText" type="textarea" placeholder="Your lorem ipsum..." readonly autosize /> | ||||
|     <br /> | ||||
|     <br /> | ||||
|     <n-space justify="center"> | ||||
|  | ||||
| @ -2,10 +2,10 @@ import { Devices } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'MAC adrdress lookup', | ||||
|   path: '/mac-adrdress-lookup', | ||||
|   name: 'MAC address lookup', | ||||
|   path: '/mac-address-lookup', | ||||
|   description: 'Find the vendor and manufacturer of a device by its MAC address.', | ||||
|   keywords: ['mac', 'adrdress', 'lookup', 'vendor', 'parser', 'manufacturer'], | ||||
|   keywords: ['mac', 'address', 'lookup', 'vendor', 'parser', 'manufacturer'], | ||||
|   component: () => import('./mac-address-lookup.vue'), | ||||
|   icon: Devices, | ||||
|   createdAt: new Date('2023-04-06'), | ||||
|  | ||||
| @ -30,23 +30,15 @@ | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import db from 'oui/oui.json'; | ||||
| import { macAddressValidation } from '@/utils/macAddress'; | ||||
| 
 | ||||
| const getVendorValue = (address: string) => address.trim().replace(/[.:-]/g, '').toUpperCase().substring(0, 6); | ||||
| 
 | ||||
| const macAddress = ref('20:37:06:12:34:56'); | ||||
| const details = computed<string | undefined>(() => db[getVendorValue(macAddress.value)]); | ||||
| 
 | ||||
| const { attrs: validationAttrs } = useValidation({ | ||||
|   source: macAddress, | ||||
|   rules: [ | ||||
|     { | ||||
|       message: 'Invalid MAC address', | ||||
|       validator: (value) => value.trim().match(/^([0-9A-Fa-f]{2}[:-]){2,5}([0-9A-Fa-f]{2})$/), | ||||
|     }, | ||||
|   ], | ||||
| }); | ||||
| const { attrs: validationAttrs } = macAddressValidation(macAddress); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped></style> | ||||
|  | ||||
| @ -0,0 +1,48 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - OTP code generator', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/otp-generator'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('OTP code generator - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Secret hexa value is computed from provided secret', async ({ page }) => { | ||||
|     await page.getByPlaceholder('Paste your TOTP secret...').fill('ITTOOLS'); | ||||
| 
 | ||||
|     const secretInHex = await page.getByPlaceholder('Secret in hex will be displayed here').inputValue(); | ||||
| 
 | ||||
|     expect(secretInHex).toEqual('44e6e72e02'); | ||||
|   }); | ||||
| 
 | ||||
|   test('OTP a generated from the provided secret', async ({ page }) => { | ||||
|     page.evaluate(() => { | ||||
|       Date.now = () => 1609477200000; //Jan 1, 2021
 | ||||
|     }); | ||||
| 
 | ||||
|     await page.getByPlaceholder('Paste your TOTP secret...').fill('ITTOOLS'); | ||||
| 
 | ||||
|     const previousOtp = await page.getByTestId('previous-otp').innerText(); | ||||
|     const currentOtp = await page.getByTestId('current-otp').innerText(); | ||||
|     const nextOtp = await page.getByTestId('next-otp').innerText(); | ||||
| 
 | ||||
|     expect(previousOtp.trim()).toEqual('028034'); | ||||
|     expect(currentOtp.trim()).toEqual('162195'); | ||||
|     expect(nextOtp.trim()).toEqual('452815'); | ||||
|   }); | ||||
| 
 | ||||
|   test('You can generate a new random secret', async ({ page }) => { | ||||
|     const initialSecret = await page.getByPlaceholder('Paste your TOTP secret...').inputValue(); | ||||
|     await page | ||||
|       .locator('div') | ||||
|       .filter({ hasText: /^Secret$/ }) | ||||
|       .getByRole('button') | ||||
|       .click(); | ||||
| 
 | ||||
|     const newSecret = await page.getByPlaceholder('Paste your TOTP secret...').inputValue(); | ||||
| 
 | ||||
|     expect(newSecret).not.toEqual(initialSecret); | ||||
|   }); | ||||
| }); | ||||
| @ -8,13 +8,21 @@ | ||||
|     <n-input-group> | ||||
|       <n-tooltip trigger="hover" placement="bottom"> | ||||
|         <template #trigger> | ||||
|           <n-button secondary @click.prevent="copyPrevious(tokens.previous)">{{ tokens.previous }}</n-button> | ||||
|           <n-button data-test-id="previous-otp" secondary @click.prevent="copyPrevious(tokens.previous)">{{ | ||||
|             tokens.previous | ||||
|           }}</n-button> | ||||
|         </template> | ||||
|         <div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div> | ||||
|       </n-tooltip> | ||||
|       <n-tooltip trigger="hover" placement="bottom"> | ||||
|         <template #trigger> | ||||
|           <n-button tertiary type="primary" class="current-otp" @click.prevent="copyCurrent(tokens.current)"> | ||||
|           <n-button | ||||
|             tertiary | ||||
|             type="primary" | ||||
|             data-test-id="current-otp" | ||||
|             class="current-otp" | ||||
|             @click.prevent="copyCurrent(tokens.current)" | ||||
|           > | ||||
|             {{ tokens.current }} | ||||
|           </n-button> | ||||
|         </template> | ||||
| @ -22,7 +30,9 @@ | ||||
|       </n-tooltip> | ||||
|       <n-tooltip trigger="hover" placement="bottom"> | ||||
|         <template #trigger> | ||||
|           <n-button secondary @click.prevent="copyNext(tokens.next)">{{ tokens.next }}</n-button> | ||||
|           <n-button secondary data-test-id="next-otp" @click.prevent="copyNext(tokens.next)">{{ | ||||
|             tokens.next | ||||
|           }}</n-button> | ||||
|         </template> | ||||
|         <div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div> | ||||
|       </n-tooltip> | ||||
|  | ||||
| @ -13,14 +13,17 @@ describe('roman-numeral-converter', () => { | ||||
|       expect(arabicToRoman(0.9)).toEqual(''); | ||||
|     }); | ||||
| 
 | ||||
|     it('should convert numbers greater than 3999 to empty string', () => { | ||||
|       expect(arabicToRoman(3999.1)).toEqual(''); | ||||
|       expect(arabicToRoman(4000)).toEqual(''); | ||||
|       expect(arabicToRoman(10000)).toEqual(''); | ||||
|     }); | ||||
| 
 | ||||
|     it('should convert floating points number to the lower integer in roman version', () => { | ||||
|       expect(arabicToRoman(-100)).toEqual(''); | ||||
|       expect(arabicToRoman(-42)).toEqual(''); | ||||
|       expect(arabicToRoman(-26)).toEqual(''); | ||||
|       expect(arabicToRoman(-10)).toEqual(''); | ||||
|       expect(arabicToRoman(0)).toEqual(''); | ||||
|       expect(arabicToRoman(0.5)).toEqual(''); | ||||
|       expect(arabicToRoman(0.9)).toEqual(''); | ||||
|       expect(arabicToRoman(1.1)).toEqual('I'); | ||||
|       expect(arabicToRoman(1.9)).toEqual('I'); | ||||
|       expect(arabicToRoman(17.6)).toEqual('XVII'); | ||||
|       expect(arabicToRoman(29.999)).toEqual('XXIX'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should convert positive integers to roman numbers', () => { | ||||
| @ -67,7 +70,6 @@ describe('roman-numeral-converter', () => { | ||||
|       expect(arabicToRoman(999)).toEqual('CMXCIX'); | ||||
|       expect(arabicToRoman(1000)).toEqual('M'); | ||||
|       expect(arabicToRoman(2000)).toEqual('MM'); | ||||
|       expect(arabicToRoman(9000)).toEqual('MMMMMMMMM'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| export const MIN_ARABIC_TO_ROMAN = 1; | ||||
| export const MAX_ARABIC_TO_ROMAN = 3999; | ||||
| export function arabicToRoman(num: number) { | ||||
|   if (num < 1) return ''; | ||||
|   if (num < MIN_ARABIC_TO_ROMAN || num > MAX_ARABIC_TO_ROMAN) return ''; | ||||
| 
 | ||||
|   const lookup: { [key: string]: number } = { | ||||
|     M: 1000, | ||||
| @ -26,7 +28,16 @@ export function arabicToRoman(num: number) { | ||||
|   return roman; | ||||
| } | ||||
| 
 | ||||
| const ROMAN_NUMBER_REGEX = new RegExp(/^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/); | ||||
| 
 | ||||
| export function isValidRomanNumber(romanNumber: string) { | ||||
|   return ROMAN_NUMBER_REGEX.test(romanNumber); | ||||
| } | ||||
| 
 | ||||
| export function romanToArabic(s: string) { | ||||
|   if (!isValidRomanNumber(s)) { | ||||
|     return null; | ||||
|   } | ||||
|   const map: { [key: string]: number } = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }; | ||||
|   return [...s].reduce((r, c, i, s) => (map[s[i + 1]] > map[c] ? r - map[c] : r + map[c]), 0); | ||||
| } | ||||
|  | ||||
| @ -2,21 +2,29 @@ | ||||
|   <div> | ||||
|     <n-card title="Arabic to roman"> | ||||
|       <n-space align="center" justify="space-between"> | ||||
|         <n-input-number v-model:value="inputNumeral" :min="1" style="width: 200px" :show-button="false" /> | ||||
|         <n-form-item v-bind="validationNumeral"> | ||||
|           <n-input-number v-model:value="inputNumeral" :min="1" style="width: 200px" :show-button="false" /> | ||||
|         </n-form-item> | ||||
|         <div class="result"> | ||||
|           {{ outputRoman }} | ||||
|         </div> | ||||
|         <n-button secondary autofocus @click="copyRoman"> Copy </n-button> | ||||
|         <n-button secondary autofocus :disabled="validationNumeral.validationStatus === 'error'" @click="copyRoman"> | ||||
|           Copy | ||||
|         </n-button> | ||||
|       </n-space> | ||||
|     </n-card> | ||||
|     <br /> | ||||
|     <n-card title="Roman to arabic"> | ||||
|       <n-space align="center" justify="space-between"> | ||||
|         <n-input v-model:value="inputRoman" style="width: 200px" /> | ||||
|         <n-form-item v-bind="validationRoman"> | ||||
|           <n-input v-model:value="inputRoman" style="width: 200px" /> | ||||
|         </n-form-item> | ||||
|         <div class="result"> | ||||
|           {{ outputNumeral }} | ||||
|         </div> | ||||
|         <n-button secondary autofocus @click="copyArabic"> Copy </n-button> | ||||
|         <n-button secondary autofocus :disabled="validationRoman.validationStatus === 'error'" @click="copyArabic"> | ||||
|           Copy | ||||
|         </n-button> | ||||
|       </n-space> | ||||
|     </n-card> | ||||
|   </div> | ||||
| @ -25,14 +33,41 @@ | ||||
| <script setup lang="ts"> | ||||
| import { useCopy } from '@/composable/copy'; | ||||
| import { ref, computed } from 'vue'; | ||||
| import { arabicToRoman, romanToArabic } from './roman-numeral-converter.service'; | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import { | ||||
|   arabicToRoman, | ||||
|   romanToArabic, | ||||
|   MAX_ARABIC_TO_ROMAN, | ||||
|   MIN_ARABIC_TO_ROMAN, | ||||
|   isValidRomanNumber, | ||||
| } from './roman-numeral-converter.service'; | ||||
| 
 | ||||
| const inputNumeral = ref(42); | ||||
| const outputRoman = computed(() => arabicToRoman(inputNumeral.value)); | ||||
| 
 | ||||
| const { attrs: validationNumeral } = useValidation({ | ||||
|   source: inputNumeral, | ||||
|   rules: [ | ||||
|     { | ||||
|       validator: (value) => value >= MIN_ARABIC_TO_ROMAN && value <= MAX_ARABIC_TO_ROMAN, | ||||
|       message: `We can only convert numbers between ${MIN_ARABIC_TO_ROMAN.toLocaleString()} and ${MAX_ARABIC_TO_ROMAN.toLocaleString()}`, | ||||
|     }, | ||||
|   ], | ||||
| }); | ||||
| 
 | ||||
| const inputRoman = ref('XLII'); | ||||
| const outputNumeral = computed(() => romanToArabic(inputRoman.value)); | ||||
| 
 | ||||
| const { attrs: validationRoman } = useValidation({ | ||||
|   source: inputRoman, | ||||
|   rules: [ | ||||
|     { | ||||
|       validator: (value) => isValidRomanNumber(value), | ||||
|       message: `The input you entered is not a valid roman number`, | ||||
|     }, | ||||
|   ], | ||||
| }); | ||||
| 
 | ||||
| const { copy: copyRoman } = useCopy({ source: outputRoman, text: 'Roman number copied to the clipboard' }); | ||||
| const { copy: copyArabic } = useCopy({ source: outputNumeral, text: 'Arabic number copied to the clipboard' }); | ||||
| </script> | ||||
|  | ||||
							
								
								
									
										19
									
								
								src/tools/token-generator/token-generator.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/tools/token-generator/token-generator.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - Token generator', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/token-generator'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('Token generator - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('New token on refresh', async ({ page }) => { | ||||
|     const initialToken = await page.getByPlaceholder('The token...').inputValue(); | ||||
|     await page.getByRole('button', { name: 'Refresh' }).click(); | ||||
|     const newToken = await page.getByPlaceholder('The token...').inputValue(); | ||||
| 
 | ||||
|     expect(newToken).not.toEqual(initialToken); | ||||
|   }); | ||||
| }); | ||||
| @ -1,17 +1,10 @@ | ||||
| import { config } from '@/config'; | ||||
| import { isAfter, subWeeks } from 'date-fns'; | ||||
| import type { Tool } from './tools.types'; | ||||
| 
 | ||||
| type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; | ||||
| 
 | ||||
| export function defineTool( | ||||
|   tool: WithOptional<Tool, 'isNew'>, | ||||
|   { newTools }: { newTools: string[] } = { newTools: config.tools.newTools }, | ||||
| ) { | ||||
|   const isInNewToolConfig = newTools.includes(tool.name); | ||||
|   const isRecentTool = tool.createdAt ? isAfter(tool.createdAt, subWeeks(new Date(), 2)) : false; | ||||
| 
 | ||||
|   const isNew = isInNewToolConfig || isRecentTool; | ||||
| export function defineTool(tool: WithOptional<Tool, 'isNew'>) { | ||||
|   const isNew = tool.createdAt ? isAfter(tool.createdAt, subWeeks(new Date(), 2)) : false; | ||||
| 
 | ||||
|   return { | ||||
|     isNew, | ||||
|  | ||||
							
								
								
									
										12
									
								
								src/tools/yaml-to-json-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/yaml-to-json-converter/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { AlignJustified } from '@vicons/tabler'; | ||||
| import { defineTool } from '../tool'; | ||||
| 
 | ||||
| export const tool = defineTool({ | ||||
|   name: 'YAML to JSON converter', | ||||
|   path: '/yaml-to-json-converter', | ||||
|   description: 'Simply convert YAML to JSON with this live online converter.', | ||||
|   keywords: ['yaml', 'to', 'json'], | ||||
|   component: () => import('./yaml-to-json.vue'), | ||||
|   icon: AlignJustified, | ||||
|   createdAt: new Date('2023-04-10'), | ||||
| }); | ||||
							
								
								
									
										31
									
								
								src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/tools/yaml-to-json-converter/yaml-to-json.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| import { test, expect } from '@playwright/test'; | ||||
| 
 | ||||
| test.describe('Tool - Yaml to json', () => { | ||||
|   test.beforeEach(async ({ page }) => { | ||||
|     await page.goto('/yaml-to-json-converter'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Has correct title', async ({ page }) => { | ||||
|     await expect(page).toHaveTitle('YAML to JSON converter - IT Tools'); | ||||
|   }); | ||||
| 
 | ||||
|   test('Yaml is parsed and output clean json', async ({ page }) => { | ||||
|     await page.getByTestId('input').fill('foo: bar\nlist:\n  - item\n  - key: value'); | ||||
| 
 | ||||
|     const generatedJson = await page.getByTestId('area-content').innerText(); | ||||
| 
 | ||||
|     expect(generatedJson.trim()).toEqual( | ||||
|       ` | ||||
| { | ||||
|    "foo": "bar", | ||||
|    "list": [ | ||||
|       "item", | ||||
|       { | ||||
|          "key": "value" | ||||
|       } | ||||
|    ] | ||||
| } | ||||
|    `.trim(),
 | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										32
									
								
								src/tools/yaml-to-json-converter/yaml-to-json.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/tools/yaml-to-json-converter/yaml-to-json.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | ||||
| <template> | ||||
|   <format-transformer | ||||
|     input-label="Your YAML" | ||||
|     input-placeholder="Paste your yaml here..." | ||||
|     output-label="JSON from your YAML" | ||||
|     output-language="json" | ||||
|     :input-validation-rules="rules" | ||||
|     :transformer="transformer" | ||||
|   /> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import type { UseValidationRule } from '@/composable/validation'; | ||||
| import { isNotThrowing } from '@/utils/boolean'; | ||||
| import { withDefaultOnError } from '@/utils/defaults'; | ||||
| import { parse as parseYaml } from 'yaml'; | ||||
| 
 | ||||
| const transformer = (value: string) => | ||||
|   withDefaultOnError(() => { | ||||
|     const obj = parseYaml(value); | ||||
|     return obj ? JSON.stringify(obj, null, 3) : ''; | ||||
|   }, ''); | ||||
| 
 | ||||
| const rules: UseValidationRule<string>[] = [ | ||||
|   { | ||||
|     validator: (value: string) => isNotThrowing(() => parseYaml(value)), | ||||
|     message: 'Provided YAML is not valid.', | ||||
|   }, | ||||
| ]; | ||||
| </script> | ||||
| 
 | ||||
| <style lang="less" scoped></style> | ||||
							
								
								
									
										16
									
								
								src/utils/macAddress.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/utils/macAddress.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| import { useValidation } from '@/composable/validation'; | ||||
| import type { Ref } from 'vue'; | ||||
| 
 | ||||
| function macAddressValidation(value: Ref) { | ||||
|   return useValidation({ | ||||
|     source: value, | ||||
|     rules: [ | ||||
|       { | ||||
|         message: 'Invalid MAC address', | ||||
|         validator: (value) => value.trim().match(/^([0-9A-Fa-f]{2}[:-]){2,5}([0-9A-Fa-f]{2})$/), | ||||
|       }, | ||||
|     ], | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export { macAddressValidation }; | ||||
							
								
								
									
										13
									
								
								vitest.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								vitest.config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| import { configDefaults, defineConfig } from 'vitest/config'; | ||||
| import path from 'path'; | ||||
| 
 | ||||
| export default defineConfig({ | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       '@': path.resolve(__dirname, './src'), | ||||
|     }, | ||||
|   }, | ||||
|   test: { | ||||
|     exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'], | ||||
|   }, | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user