diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1a6bd45 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +package-lock.json binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ed75c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/ + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..acad4ee --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,112 @@ +const path = require("path"); + +module.exports = grunt => { + grunt.initConfig({ + pkg: grunt.file.readJSON("package.json"), + clean: { + default: ["dist/"], + }, + copy: { + html: { + files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}] + }, + }, + focus: { + dev: { + include: ["html", "js", "link"], + }, + }, + replace: { + dev: { + src: ["./dist/*.html", "./dist/*.js"], + replacements: [ + { + from: "%%VERSION_NUMBER%%", + to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "") + } + ], + overwrite: true + }, + deploy: { + src: ["./dist/*.html", "./dist/*.js"], + replacements: [ + { + from: "%%VERSION_NUMBER%%", + to: "<%= pkg.version %>" + } + ], + overwrite: true + }, + }, + watch: { + html: { + files: ["src/main/**/*.html"], + tasks: ["copy:html"], + }, + js: { + files: ["src/main/**/*.js"], + tasks: ["webpack:dev", "replace:dev"], + }, + link: { + files: ["node_modules/@fwdekker/*/dist/**"], + tasks: ["webpack:dev", "replace:dev"], + }, + }, + webpack: { + options: { + entry: "./src/main/js/index.js", + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + }, + ], + }, + resolve: { + extensions: [".js"], + }, + output: { + filename: "bundle.js", + path: path.resolve(__dirname, "dist/"), + }, + }, + dev: { + mode: "development", + devtool: "inline-source-map", + }, + deploy: { + mode: "production", + }, + }, + }); + + grunt.loadNpmTasks("grunt-contrib-clean"); + grunt.loadNpmTasks("grunt-contrib-copy"); + grunt.loadNpmTasks("grunt-contrib-watch"); + grunt.loadNpmTasks("grunt-focus"); + grunt.loadNpmTasks("grunt-text-replace"); + grunt.loadNpmTasks("grunt-webpack"); + + grunt.registerTask("dev", [ + // Pre + "clean", + // Copy files + "copy:html", + // Compile JS + "webpack:dev", + "replace:dev", + ]); + grunt.registerTask("dev:server", ["dev", "focus:dev"]); + grunt.registerTask("deploy", [ + // Pre + "clean", + // Copy files + "copy:html", + // Compile JS + "webpack:deploy", + "replace:deploy", + ]); + + grunt.registerTask("default", ["dev"]); +}; diff --git a/README.md b/README.md index c2a30f0..adc9f04 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,23 @@ An online tool to convert values between number systems. Quickly convert hexadecimal to binary or base64 to ASCII. Everything happens in the browser, so nothing is submitted to any server. + +## Development +### Requirements +* [npm](https://www.npmjs.com/) + +### Setting up +```shell script +# Install dependencies (only needed once) +$> npm ci +``` + +### Building +```shell script +# Build the tool in `dist/` for development +$> npm run dev +# Same as above, but automatically rerun it whenever files are changed +$> npm run dev:server +# Build the tool in `dist/` for deployment +$> npm run deploy +``` diff --git a/index.html b/index.html deleted file mode 100644 index 78fc9be..0000000 --- a/index.html +++ /dev/null @@ -1,290 +0,0 @@ - - - - - - - - - - - Converter | FWDekker - - - - - -
- -
-
-

Converter

- -
-

Convert numbers to and from various bases.

-
-
-
- - - -
-
-
-
-
-
-
-
-
-
- - - - -
- - - - - - - - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6903f20 Binary files /dev/null and b/package-lock.json differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..caa027f --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "converter", + "version": "1.4.7", + "description": "Convert numbers to and from various bases.", + "author": "Felix W. Dekker", + "browser": "dist/bundle.js", + "repository": { + "type": "git", + "url": "git@git.fwdekker.com:FWDekker/converter.git" + }, + "private": true, + "scripts": { + "clean": "grunt clean", + "dev": "grunt dev", + "dev:server": "grunt dev:server", + "deploy": "grunt deploy" + }, + "dependencies": { + "@fwdekker/template": "^0.0.14", + "big-integer": "^1.6.48" + }, + "devDependencies": { + "grunt": "^1.1.0", + "grunt-cli": "^1.3.2", + "grunt-contrib-clean": "^2.0.0", + "grunt-contrib-copy": "^1.0.0", + "grunt-contrib-watch": "^1.1.0", + "grunt-focus": "^1.0.0", + "grunt-text-replace": "^0.4.0", + "grunt-webpack": "^3.1.3", + "webpack": "^4.42.1", + "webpack-cli": "^3.3.11" + } +} diff --git a/src/main/index.html b/src/main/index.html new file mode 100644 index 0000000..d29e23f --- /dev/null +++ b/src/main/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + Converter | FWDekker + + + + + +
+ +
+ + + + +
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + + + diff --git a/src/main/js/index.js b/src/main/js/index.js new file mode 100644 index 0000000..e0a1a9d --- /dev/null +++ b/src/main/js/index.js @@ -0,0 +1,241 @@ +import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template"; +import bigInt from "big-integer" + + +/** + * Replaces the character at the given index with the given replacement. + * + * @param str the string to replace in + * @param index the index in the given string to replace at + * @param replacement the replacement to insert into the string + * @returns {string} the input string with one character replaced + */ +const stringReplaceAt = (str, index, replacement) => + str.substr(0, index) + replacement + str.substr(index + replacement.length); + +/** + * Replaces all instances of the target with the replacement. + * + * @param str the string to replace in + * @param target the character to replace + * @param replacement the replacement to insert into the string + * @returns {string} the input string with all instances of the target replaced + */ +const stringReplaceAll = (str, target, replacement) => + str.split(target).join(replacement); + +/** + * Runs `stringReplaceAll` for each character in `targets` and `replacements`. + * + * @param str the string to replace in + * @param targets the characters to replace + * @param replacements the replacements to insert into the string; each character here corresponds to a character + * in the targets string + * @returns {string} the input string with all instances of the targets replaced + */ +const stringReplaceAlls = (str, targets, replacements) => + Array.from(targets).reduce((output, target, index) => + stringReplaceAll(output, target, replacements[index]), str); + + +class NumeralSystem { + constructor(base, alphabet, caseSensitive) { + this.base = base; + this.alphabet = alphabet; + this.caseSensitive = caseSensitive; + } + + + decimalToBase(decimalNumber) { + return decimalNumber.toString(this.base, this.alphabet); + } + + baseToDecimal(baseString) { + return bigInt(baseString, this.base, this.alphabet, this.caseSensitive); + } + + filterBaseString(baseString) { + // Regex from https://stackoverflow.com/a/3561711/ + const alphabet = this.caseSensitive + ? this.alphabet + : this.alphabet.toLowerCase() + this.alphabet.toUpperCase(); + const regexSafeAlphabet = alphabet.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); + + return baseString.replace(new RegExp(`[^${regexSafeAlphabet}]`, "g"), ""); + } +} + +class NumeralSystemInput { + constructor(name, numeralSystem) { + this.name = name; + this.numeralSystem = numeralSystem; + + this.label = document.createElement("label"); + this.label.setAttribute("for", `${this.name}Input`); + this.label.innerHTML = this.name; + + this.textarea = document.createElement("textarea"); + this.textarea.id = `${this.name}Input`; + this.textarea.className = "numberInput"; + this.textarea.oninput = () => { + if (this.textarea.value === undefined || this.textarea.value === null || this.textarea.value === "") + return; + + this.textarea.value = this.numeralSystem.filterBaseString(this.textarea.value); + updateAllInputs(this, this.numeralSystem.baseToDecimal(this.textarea.value)); + }; + } + + + addToParent(parent) { + parent.appendChild(this.label); + parent.appendChild(this.textarea); + } + + update(decimalNumber) { + this.textarea.value = this.numeralSystem.decimalToBase(decimalNumber); + } +} + +class Base64NumeralSystem extends NumeralSystem { + // TODO Convert static methods to static properties once supported by Firefox + static defaultAlphabet() { + return "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + } + + + /** + * Constructs a new base 64 numeral system. + * + * @param alphabet the 64 characters to encode numbers with, and the padding character at the end + */ + constructor(alphabet) { + super(64, alphabet, true); + } + + + decimalToBase(decimalNumber) { + const hex = decimalNumber.toString(16); + const b64 = Array.from(hex.padStart(hex.length + hex.length % 2)) + .reduce((result, value, index, array) => { + if (index % 2 === 0) result.push(array.slice(index, index + 2)); + return result; + }, []) + .map(pair => String.fromCharCode(parseInt(pair.join(""), 16))) + .join(""); + + return stringReplaceAlls(btoa(b64), Base64NumeralSystem.defaultAlphabet(), this.alphabet); + } + + baseToDecimal(baseString) { + if (baseString.length % 4 === 1) throw new Error("Invalid input string length."); + + const normalBaseString = stringReplaceAlls(baseString, this.alphabet, Base64NumeralSystem.defaultAlphabet()); + const hex = Array.from(atob(normalBaseString)) + .map(char => char.charCodeAt(0).toString(16).padStart(2, "0")).join(""); + return bigInt(hex, 16); + } +} + +class Base64NumeralSystemInput extends NumeralSystemInput { + // TODO Convert static methods to static properties once supported by Firefox + static dropdownOptions() { + return {"Standard": ['+', '/'], "Filename": ['-', '_'], "IMAP": ['+', ',']}; + } + + + constructor(name) { + super(name, new Base64NumeralSystem(Base64NumeralSystem.defaultAlphabet())); + + this.dropdown = document.createElement("select"); + this.dropdown.id = `${this.name}Dropdown`; + this.dropdown.onchange = () => { + const selectedOption = Base64NumeralSystemInput.dropdownOptions()[this.dropdown.value]; + this.setLastDigits(selectedOption[0], selectedOption[1]); + }; + + this.dropdownDiv = document.createElement("div"); + this.dropdownDiv.classList.add("float-right"); + + this.options = + Object.keys(Base64NumeralSystemInput.dropdownOptions()).map(key => { + const option = document.createElement("option"); + option.value = key; + option.text = key + ": " + Base64NumeralSystemInput.dropdownOptions()[key].join(""); + return option; + }); + } + + + setLastDigits(c62, c63) { + const oc62 = this.numeralSystem.alphabet[62]; + const oc63 = this.numeralSystem.alphabet[63]; + + this.numeralSystem.alphabet = + stringReplaceAt(stringReplaceAt(this.numeralSystem.alphabet, 62, c62), 63, c63); + this.textarea.value = + stringReplaceAll(stringReplaceAll(this.textarea.value, oc62, c62), oc63, c63); + } + + addToParent(parent) { + this.options.forEach(option => this.dropdown.appendChild(option)); + this.dropdownDiv.appendChild(this.dropdown); + parent.appendChild(this.dropdownDiv); + + parent.appendChild(this.label); + parent.appendChild(this.textarea); + } +} + + +const inputs = [ + new NumeralSystemInput("Binary", new NumeralSystem(2, "01")), + new NumeralSystemInput("Octal", new NumeralSystem(8, "01234567")), + new NumeralSystemInput("Decimal", new NumeralSystem(10, "0123456789")), + new NumeralSystemInput("Duodecimal", new NumeralSystem(12, "0123456789ab", false)), + new NumeralSystemInput("Hexadecimal", new NumeralSystem(16, "0123456789abcdef", false)), + new Base64NumeralSystemInput("Base64"), + new NumeralSystemInput( + "ASCII", + new NumeralSystem( + 256, + new Array(256).fill(0).map((_, it) => String.fromCharCode(it)).join(""), + true + ) + ), +]; + +const updateAllInputs = (source, newValue) => { + for (const input of inputs) + if (input !== source) + input.update(newValue); +}; + + +doAfterLoad(() => { + $("#nav").appendChild(nav()); + $("#header").appendChild(header({ + title: "Converter", + description: "Convert numbers to and from various bases" + })); + $("#footer").appendChild(footer({ + author: "Felix W. Dekker", + authorURL: "https://fwdekker.com/", + license: "MIT License", + licenseURL: "https://git.fwdekker.com/FWDekker/converter/src/branch/master/LICENSE", + vcs: "git", + vcsURL: "https://git.fwdekker.com/FWDekker/converter/", + version: "v%%VERSION_NUMBER%%" + })); + $("main").style.display = null; +}); + +doAfterLoad(() => { + const inputParent = $("#inputs"); + + for (const input of inputs) + input.addToParent(inputParent); + + updateAllInputs(undefined, bigInt(42)); + inputs[0].textarea.focus(); +});