mirror of https://github.com/keeweb/keeweb.git
generator presets
This commit is contained in:
parent
ef23e91d34
commit
ab76e3f90d
|
@ -51,6 +51,16 @@ var Locale = {
|
|||
footerTitleLock: 'Lock',
|
||||
|
||||
genLen: 'Length',
|
||||
genPresetDefault: 'default preset',
|
||||
genPresetDerived: 'like old password',
|
||||
genPresetPronounceable: 'pronounceable',
|
||||
genPresetMed: 'medium length',
|
||||
genPresetLong: 'long',
|
||||
genPresetPin4: '4-digit PIN',
|
||||
genPresetMac: 'MAC address',
|
||||
genPresetHash128: '128-bit hash',
|
||||
genPresetHash256: '256-bit hash',
|
||||
|
||||
grpTitle: 'Group',
|
||||
grpSearch: 'Enable searching entries in this group',
|
||||
|
||||
|
@ -243,7 +253,7 @@ var Locale = {
|
|||
setFileHistory: 'History',
|
||||
setFileEnableTrash: 'Enable trash',
|
||||
setFileHistLen: 'History length, keep last records per entry',
|
||||
resFileHistSize: 'History size, total MB per file',
|
||||
setFileHistSize: 'History size, total MB per file',
|
||||
setFileAdvanced: 'Advanced',
|
||||
setFileRounds: 'Key encryption rounds',
|
||||
setFileUseKeyFile: 'Use key file',
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
var kdbxweb = require('kdbxweb');
|
||||
var kdbxweb = require('kdbxweb'),
|
||||
phonetic = require('./phonetic');
|
||||
|
||||
var PasswordGenerator = {
|
||||
charRanges: {
|
||||
|
@ -12,10 +13,21 @@ var PasswordGenerator = {
|
|||
high: '¡¢£¤¥¦§©ª«¬®¯°±²³´µ¶¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþ',
|
||||
ambiguous: 'O0oIl'
|
||||
},
|
||||
|
||||
generate: function(opts) {
|
||||
if (!opts || typeof opts.length !== 'number' || opts.length < 0) {
|
||||
return '';
|
||||
}
|
||||
switch (opts.name) {
|
||||
case 'Pronounceable':
|
||||
return this.generatePronounceable(opts);
|
||||
case 'Hash128':
|
||||
return this.generateHash(32);
|
||||
case 'Hash256':
|
||||
return this.generateHash(64);
|
||||
case 'Mac':
|
||||
return this.generateMac();
|
||||
}
|
||||
var ranges = Object.keys(this.charRanges)
|
||||
.filter(function(r) { return opts[r]; })
|
||||
.map(function(r) { return this.charRanges[r]; }, this);
|
||||
|
@ -31,6 +43,69 @@ var PasswordGenerator = {
|
|||
}
|
||||
return _.shuffle(chars).join('');
|
||||
},
|
||||
|
||||
generateMac: function() {
|
||||
var segmentsCount = 6;
|
||||
var randomBytes = kdbxweb.Random.getBytes(segmentsCount);
|
||||
var result = '';
|
||||
for (var i = 0; i < segmentsCount; i++) {
|
||||
var segment = randomBytes[i].toString(16).toUpperCase();
|
||||
if (segment.length < 2) {
|
||||
segment = '0' + segment;
|
||||
}
|
||||
result += (result ? '-' : '') + segment;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
generateHash: function(length) {
|
||||
var randomBytes = kdbxweb.Random.getBytes(length);
|
||||
var result = '';
|
||||
for (var i = 0; i < length; i++) {
|
||||
result += randomBytes[i].toString(16)[0];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
generatePronounceable: function(opts) {
|
||||
var pass = phonetic.generate({ length: opts.length });
|
||||
var result = '';
|
||||
var upper = [];
|
||||
var i;
|
||||
if (opts.upper) {
|
||||
for (i = 0; i < pass.length; i += 8) {
|
||||
upper.push(Math.floor(Math.random() * opts.length));
|
||||
}
|
||||
}
|
||||
for (i = 0; i < pass.length; i++) {
|
||||
var ch = pass[i];
|
||||
if (upper.indexOf(i) >= 0) {
|
||||
ch = ch.toUpperCase();
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result.substr(0, opts.length);
|
||||
},
|
||||
|
||||
deriveOpts: function(password) {
|
||||
var opts = {};
|
||||
var length = 0;
|
||||
if (password) {
|
||||
var charRanges = this.charRanges;
|
||||
password.forEachChar(function(ch) {
|
||||
length++;
|
||||
ch = String.fromCharCode(ch);
|
||||
_.forEach(charRanges, function(chars, range) {
|
||||
if (chars.indexOf(ch) >= 0) {
|
||||
opts[range] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
opts.length = length;
|
||||
return opts;
|
||||
},
|
||||
|
||||
present: function(length) {
|
||||
return new Array(length + 1).join('•');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Phonetic
|
||||
* Copyright 2013 Tom Frost
|
||||
*/
|
||||
|
||||
// removed node.js deps, making it available to load in browser
|
||||
|
||||
/**
|
||||
* Phonetics that sound best before a vowel.
|
||||
* @type {Array}
|
||||
*/
|
||||
var PHONETIC_PRE = [
|
||||
// Simple phonetics
|
||||
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p',
|
||||
'qu', 'r', 's', 't',
|
||||
// Complex phonetics
|
||||
'bl',
|
||||
'ch', 'cl', 'cr',
|
||||
'dr',
|
||||
'fl', 'fr',
|
||||
'gl', 'gr',
|
||||
'kl', 'kr',
|
||||
'ph', 'pr', 'pl',
|
||||
'sc', 'sh', 'sl', 'sn', 'sr', 'st', 'str', 'sw',
|
||||
'th', 'tr',
|
||||
'br',
|
||||
'v', 'w', 'y', 'z'
|
||||
];
|
||||
|
||||
/**
|
||||
* The number of simple phonetics within the 'pre' set.
|
||||
* @type {number}
|
||||
*/
|
||||
var PHONETIC_PRE_SIMPLE_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Vowel sound phonetics.
|
||||
* @type {Array}
|
||||
*/
|
||||
var PHONETIC_MID = [
|
||||
// Simple phonetics
|
||||
'a', 'e', 'i', 'o', 'u',
|
||||
// Complex phonetics
|
||||
'ee', 'ie', 'oo', 'ou', 'ue'
|
||||
];
|
||||
|
||||
/**
|
||||
* The number of simple phonetics within the 'mid' set.
|
||||
* @type {number}
|
||||
*/
|
||||
var PHONETIC_MID_SIMPLE_LENGTH = 5;
|
||||
|
||||
/**
|
||||
* Phonetics that sound best after a vowel.
|
||||
* @type {Array}
|
||||
*/
|
||||
var PHONETIC_POST = [
|
||||
// Simple phonetics
|
||||
'b', 'd', 'f', 'g', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'y',
|
||||
// Complex phonetics
|
||||
'ch', 'ck',
|
||||
'ln',
|
||||
'nk', 'ng',
|
||||
'rn',
|
||||
'sh', 'sk', 'st',
|
||||
'th',
|
||||
'x', 'z'
|
||||
];
|
||||
|
||||
/**
|
||||
* The number of simple phonetics within the 'post' set.
|
||||
* @type {number}
|
||||
*/
|
||||
var PHONETIC_POST_SIMPLE_LENGTH = 13;
|
||||
|
||||
/**
|
||||
* A mapping of regular expressions to replacements, which will be run on the
|
||||
* resulting word before it gets returned. The purpose of replacements is to
|
||||
* address language subtleties that the phonetic builder is incapable of
|
||||
* understanding, such as 've' more pronounceable than just 'v' at the end of
|
||||
* a word, 'ey' more pronounceable than 'iy', etc.
|
||||
* @type {{}}
|
||||
*/
|
||||
var REPLACEMENTS = {
|
||||
'quu': 'que',
|
||||
'qu([aeiou]){2}': 'qu$1',
|
||||
'[iu]y': 'ey',
|
||||
'eye': 'ye',
|
||||
'(.)ye$': '$1y',
|
||||
'(^|e)cie(?!$)': '$1cei',
|
||||
'([vz])$': '$1e',
|
||||
'[iu]w': 'ow'
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a single syllable to the word contained in the wordObj. A syllable
|
||||
* contains, at maximum, a phonetic from each the PRE, MID, and POST phonetic
|
||||
* sets. Some syllables will omit pre or post based on the
|
||||
* options.compoundSimplicity.
|
||||
*
|
||||
* @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The
|
||||
* word object on which to operate.
|
||||
*/
|
||||
function addSyllable(wordObj) {
|
||||
var deriv = getDerivative(wordObj.numeric),
|
||||
compound = deriv % wordObj.opts.compoundSimplicity === 0,
|
||||
first = wordObj.word === '',
|
||||
preOnFirst = deriv % 6 > 0;
|
||||
if ((first && preOnFirst) || wordObj.lastSkippedPost || compound) {
|
||||
wordObj.word += getNextPhonetic(PHONETIC_PRE,
|
||||
PHONETIC_PRE_SIMPLE_LENGTH, wordObj);
|
||||
wordObj.lastSkippedPre = false;
|
||||
} else {
|
||||
wordObj.lastSkippedPre = true;
|
||||
}
|
||||
wordObj.word += getNextPhonetic(PHONETIC_MID, PHONETIC_MID_SIMPLE_LENGTH,
|
||||
wordObj, first && wordObj.lastSkippedPre);
|
||||
if (wordObj.lastSkippedPre || compound) {
|
||||
wordObj.word += getNextPhonetic(PHONETIC_POST,
|
||||
PHONETIC_POST_SIMPLE_LENGTH, wordObj);
|
||||
wordObj.lastSkippedPost = false;
|
||||
} else {
|
||||
wordObj.lastSkippedPost = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a derivative of a number by repeatedly dividing it by 7 and adding the
|
||||
* remainders together. It's useful to base decisions on a derivative rather
|
||||
* than the wordObj's current numeric, as it avoids making the same decisions
|
||||
* around the same phonetics.
|
||||
*
|
||||
* @param {number} num A number from which a derivative should be calculated
|
||||
* @returns {number} The derivative.
|
||||
*/
|
||||
function getDerivative(num) {
|
||||
var derivative = 1;
|
||||
while (num) {
|
||||
derivative += num % 7;
|
||||
num = Math.floor(num / 7);
|
||||
}
|
||||
return derivative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the option defaults with the provided overrides. Available
|
||||
* options are:
|
||||
* - seed: A string or number with which to seed the generator. Using the
|
||||
* same seed (with the same other options) will coerce the generator
|
||||
* into producing the same word. Default is random.
|
||||
* - phoneticSimplicity: The greater this number, the simpler the phonetics.
|
||||
* For example, 1 might produce 'str' while 5 might produce 's' for
|
||||
* the same syllable. Minimum is 1, default is 5.
|
||||
* - compoundSimplicity: The greater this number, the less likely the
|
||||
* resulting word will sound "compound", such as "ripkuth" instead of
|
||||
* "riputh". Minimum is 1, default is 5.
|
||||
*
|
||||
* @param {{}} overrides A set of options and values with which to override
|
||||
* the defaults.
|
||||
* @returns {{seed, phoneticSimplicity, compoundSimplicity}}
|
||||
* An options object.
|
||||
*/
|
||||
function getOptions(overrides) {
|
||||
var options = {};
|
||||
overrides = overrides || {};
|
||||
options.length = overrides.length || 16;
|
||||
options.seed = overrides.seed || Math.random();
|
||||
options.phoneticSimplicity = overrides.phoneticSimplicity ?
|
||||
Math.max(overrides.phoneticSimplicity, 1) : 5;
|
||||
options.compoundSimplicity = overrides.compoundSimplicity ?
|
||||
Math.max(overrides.compoundSimplicity, 1) : 5;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next pseudo-random phonetic from a given phonetic set,
|
||||
* intelligently determining whether to include "complex" phonetics in that
|
||||
* set based on the options.phoneticSimplicity.
|
||||
*
|
||||
* @param {Array} phoneticSet The array of phonetics from which to choose
|
||||
* @param {number} simpleCap The number of 'simple' phonetics at the beginning
|
||||
* of the phoneticSet
|
||||
* @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The
|
||||
* wordObj for which the phonetic is being chosen
|
||||
* @param {boolean} [forceSimple] true to force a simple phonetic to be
|
||||
* chosen; otherwise, the function will choose whether to include complex
|
||||
* phonetics based on the derivative of wordObj.numeric.
|
||||
* @returns {string} The chosen phonetic.
|
||||
*/
|
||||
function getNextPhonetic(phoneticSet, simpleCap, wordObj, forceSimple) {
|
||||
var deriv = getDerivative(wordObj.numeric),
|
||||
simple = (wordObj.numeric + deriv) % wordObj.opts.phoneticSimplicity > 0,
|
||||
cap = simple || forceSimple ? simpleCap : phoneticSet.length,
|
||||
phonetic = phoneticSet[wordObj.numeric % cap];
|
||||
wordObj.numeric = getNumericHash(wordObj.numeric + wordObj.word);
|
||||
return phonetic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a numeric hash based on the input data. The hash is an md5, with
|
||||
* each block of 32 bits converted to an integer and added together.
|
||||
*
|
||||
* @param {string|number} data The string or number to be hashed.
|
||||
* @returns {number}
|
||||
*/
|
||||
function getNumericHash(data) {
|
||||
var numeric = 0;
|
||||
data += '-Phonetic';
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
numeric += data.charCodeAt(i);
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies post-processing to a word after it has already been generated. In
|
||||
* this phase, the REPLACEMENTS are executed, applying language intelligence
|
||||
* that can make generated words more pronounceable. The first letter is
|
||||
* also capitalized.
|
||||
*
|
||||
* @param {{word, numeric, lastSkippedPre, lastSkippedPost, opts}} wordObj The
|
||||
* word object to be processed.
|
||||
* @returns {string} The processed word.
|
||||
*/
|
||||
function postProcess(wordObj) {
|
||||
var regex;
|
||||
for (var i in REPLACEMENTS) {
|
||||
if (REPLACEMENTS.hasOwnProperty(i)) {
|
||||
regex = new RegExp(i);
|
||||
wordObj.word = wordObj.word.replace(regex, REPLACEMENTS[i]);
|
||||
}
|
||||
}
|
||||
return wordObj.word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new word based on the given options. For available options,
|
||||
* see getOptions.
|
||||
*
|
||||
* @param {*} [options] A collection of options to control the word generator.
|
||||
* @returns {string} A generated word.
|
||||
*/
|
||||
module.exports.generate = function(options) {
|
||||
options = getOptions(options);
|
||||
var length = options.length,
|
||||
wordObj = {
|
||||
numeric: getNumericHash(options.seed),
|
||||
lastSkippedPost: false,
|
||||
word: '',
|
||||
opts: options
|
||||
};
|
||||
while (wordObj.word.length < length) {
|
||||
addSyllable(wordObj);
|
||||
}
|
||||
return postProcess(wordObj).substr(0, length);
|
||||
};
|
|
@ -56,7 +56,7 @@ var FieldViewText = FieldView.extend({
|
|||
this.hideGenerator();
|
||||
} else {
|
||||
var fieldRect = this.input[0].getBoundingClientRect();
|
||||
this.gen = new GeneratorView({model: {pos: {left: fieldRect.left, top: fieldRect.bottom}}}).render();
|
||||
this.gen = new GeneratorView({model: {pos: {left: fieldRect.left, top: fieldRect.bottom}, password: this.value}}).render();
|
||||
this.gen.once('remove', this.generatorClosed.bind(this));
|
||||
this.gen.once('result', this.generatorResult.bind(this));
|
||||
}
|
||||
|
|
|
@ -5,10 +5,6 @@ var Backbone = require('backbone'),
|
|||
CopyPaste = require('../comp/copy-paste'),
|
||||
Locale = require('../util/locale');
|
||||
|
||||
var DefaultGenOpts = {
|
||||
length: 16, upper: true, lower: true, digits: true, special: false, brackets: false, high: false, ambiguous: false
|
||||
};
|
||||
|
||||
var GeneratorView = Backbone.View.extend({
|
||||
el: 'body',
|
||||
|
||||
|
@ -20,26 +16,67 @@ var GeneratorView = Backbone.View.extend({
|
|||
'mousemove .gen__length-range': 'lengthChange',
|
||||
'change .gen__length-range': 'lengthChange',
|
||||
'change .gen__check input[type=checkbox]': 'checkChange',
|
||||
'click .gen__btn-ok': 'btnOkClick'
|
||||
'click .gen__btn-ok': 'btnOkClick',
|
||||
'change .gen__sel-tpl': 'templateChange'
|
||||
},
|
||||
|
||||
valuesMap: [3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,22,24,26,28,30,32,48,64],
|
||||
|
||||
presets: null,
|
||||
preset: null,
|
||||
|
||||
initialize: function () {
|
||||
this.createPresets();
|
||||
var preset = this.preset;
|
||||
this.gen = _.clone(_.find(this.presets, function(pr) { return pr.name === preset; }));
|
||||
this.gen = _.clone(this.presets[1]);
|
||||
$('body').one('click', this.remove.bind(this));
|
||||
this.gen = _.clone(DefaultGenOpts);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var canCopy = document.queryCommandSupported('copy');
|
||||
var btnTitle = this.model.copy ? canCopy ? Locale.alertCopy : Locale.alertClose : Locale.alertOk;
|
||||
this.renderTemplate({ btnTitle: btnTitle, opt: this.gen });
|
||||
this.renderTemplate({ btnTitle: btnTitle, opt: this.gen, presets: this.presets, preset: this.preset });
|
||||
this.resultEl = this.$el.find('.gen__result');
|
||||
this.$el.css(this.model.pos);
|
||||
this.generate();
|
||||
return this;
|
||||
},
|
||||
|
||||
createPresets: function() {
|
||||
this.presets = [
|
||||
{ name: 'Default', length: 16, upper: true, lower: true, digits: true },
|
||||
{ name: 'Pronounceable', length: 10, lower: true, upper: true },
|
||||
{ name: 'Med', length: 16, upper: true, lower: true, digits: true, special: true, brackets: true, ambiguous: true },
|
||||
{ name: 'Long', length: 32, upper: true, lower: true, digits: true },
|
||||
{ name: 'Pin4', length: 4, digits: true },
|
||||
{ name: 'Mac', length: 17, upper: true, digits: true, special: true },
|
||||
{ name: 'Hash128', length: 32, lower: true, digits: true },
|
||||
{ name: 'Hash256', length: 64, lower: true, digits: true }
|
||||
];
|
||||
if (this.model.password) {
|
||||
var derivedPreset = { name: 'Derived' };
|
||||
_.extend(derivedPreset, PasswordGenerator.deriveOpts(this.model.password));
|
||||
for (var i = 0; i < this.valuesMap.length; i++) {
|
||||
if (this.valuesMap[i] >= derivedPreset.length) {
|
||||
derivedPreset.length = this.valuesMap[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (derivedPreset.length > this.valuesMap[this.valuesMap.length - 1]) {
|
||||
derivedPreset.length = this.valuesMap[this.valuesMap.length - 1];
|
||||
}
|
||||
this.presets.splice(1, 0, derivedPreset);
|
||||
this.preset = 'Derived';
|
||||
} else {
|
||||
this.preset = 'Default';
|
||||
}
|
||||
this.presets.forEach(function(pr) {
|
||||
pr.pseudoLength = this.valuesMap.indexOf(pr.length);
|
||||
pr.title = Locale['genPreset' + pr.name];
|
||||
}, this);
|
||||
},
|
||||
|
||||
click: function(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
|
@ -49,6 +86,7 @@ var GeneratorView = Backbone.View.extend({
|
|||
if (val !== this.gen.length) {
|
||||
this.gen.length = val;
|
||||
this.$el.find('.gen__length-range-val').html(val);
|
||||
this.optionChanged('length');
|
||||
this.generate();
|
||||
}
|
||||
},
|
||||
|
@ -58,9 +96,19 @@ var GeneratorView = Backbone.View.extend({
|
|||
if (id) {
|
||||
this.gen[id] = e.target.checked;
|
||||
}
|
||||
this.optionChanged(id);
|
||||
this.generate();
|
||||
},
|
||||
|
||||
optionChanged: function(option) {
|
||||
if (this.preset === 'Custom' ||
|
||||
this.preset === 'Pronounceable' && ['length', 'lower', 'upper'].indexOf(option) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.preset = this.gen.name = 'Custom';
|
||||
this.$el.find('.gen__sel-tpl').val('');
|
||||
},
|
||||
|
||||
generate: function() {
|
||||
this.password = PasswordGenerator.generate(this.gen);
|
||||
this.resultEl.text(this.password);
|
||||
|
@ -77,6 +125,14 @@ var GeneratorView = Backbone.View.extend({
|
|||
CopyPaste.copy(this.password);
|
||||
this.trigger('result', this.password);
|
||||
this.remove();
|
||||
},
|
||||
|
||||
templateChange: function(e) {
|
||||
var name = e.target.value;
|
||||
this.preset = name;
|
||||
var preset = _.find(this.presets, function(t) { return t.name === name; });
|
||||
this.gen = _.clone(preset);
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -5,6 +5,10 @@
|
|||
width: 11em;
|
||||
&__length-range {
|
||||
}
|
||||
&__sel-tpl {
|
||||
width: 100%;
|
||||
margin-top: $base-padding-v;
|
||||
}
|
||||
&__check {
|
||||
width: 40%;
|
||||
display: inline-block;
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
<div class="gen">
|
||||
<div>{{res 'genLen'}}: <span class="gen__length-range-val">{{opt.length}}</span></div>
|
||||
<input type="range" class="gen__length-range" value="13" min="0" max="25" />
|
||||
<select class="gen__sel-tpl input-base">
|
||||
{{#each presets as |ps|}}
|
||||
<option value="{{ps.name}}" {{#ifeq ps.name ../preset}}selected{{/ifeq}}>{{ps.title}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<input type="range" class="gen__length-range" value="{{opt.pseudoLength}}" min="0" max="25" />
|
||||
<div>
|
||||
<div class="gen__check"><input type="checkbox" id="gen__check-upper"
|
||||
data-id="upper" {{#if opt.upper}}checked{{/if}}><label for="gen__check-upper">ABC</label></div>
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
<li><a href="https://github.com/Diokuz/baron" target="_blank">baron</a><span class="muted-color">, native scroll with custom scrollbar</span></li>
|
||||
<li><a href="http://dbushell.github.io/Pikaday/" target="_blank">pikaday</a><span class="muted-color">, a refreshing JavaScript datepicker</span></li>
|
||||
<li><a href="https://github.com/eligrey/FileSaver.js" target="_blank">filesaver.js</a><span class="muted-color">, HTML5 saveAs FileSaver implementation</span></li>
|
||||
<li><a href="https://github.com/TomFrost/node-phonetic" target="_blank">node-phonetic</a><span class="muted-color">, generates unique, pronounceable names</span></li>
|
||||
</ul>
|
||||
|
||||
<h3>Desktop modules</h3>
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</div>
|
||||
<label for="settings__file-hist-len">{{res 'setFileHistLen'}}:</label>
|
||||
<input type="text" pattern="\d+" required class="settings__input input-base" id="settings__file-hist-len" value="{{historyMaxItems}}" />
|
||||
<label for="settings__file-hist-size">{{res 'resFileHistSize'}}:</label>
|
||||
<label for="settings__file-hist-size">{{res 'setFileHistSize'}}:</label>
|
||||
<input type="text" pattern="\d+" required class="settings__input input-base" id="settings__file-hist-size" value="{{historyMaxSize}}" />
|
||||
|
||||
<h2>{{res 'setFileAdvanced'}}</h2>
|
||||
|
|
Loading…
Reference in New Issue