import QtQuick 2.15 /// \note ensures data model always has consecutive Separator and Number after Base without duplicates except current element /// \note for future work: split deleteInContent in deleteInContent and deleteElements and move data model to a DataModel object; /// also fix code duplication in parseDerivationPath generating static level definitions and iterate through it /// \note using Item to support embedded sub-components Item { id: root required property color enabledColor required property color frozenColor required property color errorColor required property color warningColor /// Don't allow inserting more than \c levelsLimit Number elements property int levelsLimit: 0 property bool complainTooBigAccIndex: true readonly property string inputError: qsTr("Please enter numbers only") readonly property string tooBigError: qsTr("Account number must be <100") readonly property string nonEthCoinWarning: qsTr("Non-Ethereum cointype") QtObject { id: d // d flag and named capture groups not supported in Qt 5.15. Use multiple regexes instead // Captured groups: ? ? ? ? readonly property var derivationPathRegex: /^m\/44[’|']\/?(?:(.*?)[’|'])?(.*?)(?:\/(.*?)[’|']?)?(?:\/(.*?))?((?:\/.*?)?)$/ // The expected characters before each group. Workaround to missing capture group offsets in Qt 5.15 readonly property var offsets: [6, 0, 2, 2] readonly property int addressIndexStart: 3 readonly property int ethereumCoinType: 60 // Reference derivation path used to normalize separators (hardened or not). The last separator will be used property var referenceElements: [] function initializeReferenceElementsIfRequired() { if (referenceElements.length === 0) { referenceElements = root.parseDerivationPath("m/44'/0'/1'/2/3") } } function createElement(content, startIndex, endIndex, contentType, isFrozen = false) { return elementComponent.createObject(root, { content: content, startIndex: startIndex, endIndex: endIndex, contentType: contentType, isFrozen: isFrozen}); } } /// Returns null if the derivationPath is invalid or an array of Element objects if derivationPath is valid function parseDerivationPath(derivationPath) { const matches = d.derivationPathRegex.exec(derivationPath) if (matches === null) { return null } var elements = [] var groupIndex = 0 var currentIndex = 0 // Extract the captured groups const coin_type_hardened = matches[1] const coin_type_simple = matches[2] const coin_type = coin_type_hardened || coin_type_simple const account = matches[3] const change = matches[4] const addressIndexes = matches[5] ? matches[5].substring(1) : ""; // remove the leading slash var nextIndex = coin_type != null ? d.offsets[groupIndex] : derivationPath.length elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Base)) if (coin_type != null && nextIndex < derivationPath.length) { currentIndex = nextIndex nextIndex = currentIndex + coin_type.length elements.push(d.createElement(coin_type, currentIndex, nextIndex, Element.ContentType.Number)) groupIndex += 2 if(coin_type_simple && derivationPath.length > nextIndex) { return null } // Standard separator if there is an account otherwise keep the rest currentIndex = nextIndex nextIndex = (account != null ? currentIndex + d.offsets[groupIndex] : derivationPath.length) if(currentIndex < nextIndex) { elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator)) } if(account != null) { currentIndex = nextIndex nextIndex = currentIndex + account.length elements.push(d.createElement(account, currentIndex, nextIndex, Element.ContentType.Number)) groupIndex++ currentIndex = nextIndex nextIndex = (change != null ? currentIndex + d.offsets[groupIndex] : derivationPath.length) if(currentIndex < nextIndex) { elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator)) } if(change != null) { currentIndex = nextIndex nextIndex = currentIndex + change.length elements.push(d.createElement(change, currentIndex, nextIndex, Element.ContentType.Number)) // Check if there are any address indexes if (matches[5] != null && matches[5].length > 0) { const addressIndexesParts = addressIndexes.split('/') for (const addressIndex of addressIndexesParts) { currentIndex = nextIndex nextIndex = currentIndex + 1 elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator)) currentIndex = nextIndex nextIndex = currentIndex + addressIndex.length elements.push(d.createElement(addressIndex, currentIndex, nextIndex, Element.ContentType.Number)) } } } else if(addressIndexes.length > 0) { return null } } else if(change || addressIndexes.length > 0) { return null } } else if(account || change || addressIndexes.length > 0) { return null } return elements } function generateHtmlFromElements(elements) { // d class is disabled; e class is editable; f class is error; w class is warning var res = `` const format = (content, cssClass) => `${content}` var numberLevel = 0 for(var i = 0; i < elements.length; i++) { if (elements[i].isFrozen) { res += format(elements[i].content, "d") } else if(validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1).error.length > 0) { res += format(elements[i].content, "f") } else if(validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1).warning.length > 0) { res += format(elements[i].content, "w") } else { res += format(elements[i].content, "e") } if(elements[i].isNumber()) { numberLevel++ } } return res } Component { id: completedDerivationPathComponent QtObject { required property var elements required property string errorMessage } } /// Matches the derivation path with a base path and freezes the base path elements /// It also completes last separator and content if needed /// \return CompletedDerivationPath function completeDerivationPath(basePath, derivationPath) { const errorResponse = () => { const res = completedDerivationPathComponent.createObject(root, { elements: [], errorMessage: root.inputError }) return res } const baseElements = root.parseDerivationPath(basePath) if (!baseElements) { console.info(`Invalid base of derivation path: ${basePath}`) return errorResponse() } var elements = root.parseDerivationPath(derivationPath) if (!elements) { console.info(`Invalid derivation path: ${derivationPath}`) return errorResponse() } if(baseElements.length > elements.length) { console.info(`Base path elements length (${baseElements.length}) bigger than path length (${elements.length})`) return errorResponse() } for(const i in elements) { if(i < baseElements.length) { if(!baseElements[i].isSimilar(elements[i])) { console.warn(`Base content "${baseElements[i].content}" doesn't match derivation path content "${elements[i].content}" for index ${i}`) return errorResponse() } elements[i].isFrozen = true } else { elements[i].isFrozen = !elements[i].isNumber() } } if(elements[elements.length - 1].isBase() && (elements[elements.length - 1].content.slice(-1) === "'" || elements[elements.length - 1].content.slice(-1) === "’")) { elements[elements.length - 1].content += "/" } if(elements[elements.length - 1].isSeparator() || elements[elements.length - 1].isBase()) { elements.push(d.createElement("", elements[elements.length - 1].endIndex, elements[elements.length - 1].endIndex, Element.ContentType.Number)) } normalizeAndRemoveDuplicateSeparators(elements, elements.length - 1) let res = completedDerivationPathComponent.createObject(root, { elements: elements, errorMessage: "" }) return res } /// \return true if an element was added function tryAddAndFixSeparators(elements, insertIndex, insertLeft = false) { if(root.levelsLimit > 0 && countNonEmptyLevels(elements) >= root.levelsLimit) { return -1 } return addAndFixSeparators(elements, insertIndex, insertLeft) } /// \return the newly created element's index or -1 if no element was created function addAndFixSeparators(elements, insertIndex, insertLeft = false) { var finalInsertIndex = insertIndex var newContentIndex = 0 if(insertLeft) { while(finalInsertIndex >= 0 && elements[finalInsertIndex].isFrozen) { finalInsertIndex-- } if(finalInsertIndex >= 0) { newContentIndex = finalInsertIndex elements.splice(newContentIndex, 0, d.createElement("", elements[finalInsertIndex - 1].endIndex, elements[finalInsertIndex - 1].endIndex, Element.ContentType.Number)) elements.splice(finalInsertIndex + 1, 0, d.createElement("/", elements[finalInsertIndex].endIndex, elements[finalInsertIndex].endIndex + 1, Element.ContentType.Separator, true)) } else { insertLeft = false finalInsertIndex = insertIndex } } if(!insertLeft) { while(finalInsertIndex < elements.length && !elements[finalInsertIndex].isFrozen) { finalInsertIndex++ } elements.splice(finalInsertIndex, 0, d.createElement("/", elements[finalInsertIndex - 1].endIndex, elements[finalInsertIndex - 1].endIndex + 1, Element.ContentType.Separator, true)) newContentIndex = finalInsertIndex + 1 elements.splice(newContentIndex, 0, d.createElement("", elements[finalInsertIndex].endIndex, elements[finalInsertIndex].endIndex, Element.ContentType.Number)) } normalizeAndRemoveDuplicateSeparators(elements, newContentIndex) return findFirstEmptyContent(elements) } function insertContent(elements, elementIndex, text, cursorPos) { while(elementIndex < (elements.length - 1) && (!elements[elementIndex].isNumber() || elements[elementIndex].isFrozen)) { elementIndex++ } if(cursorPos < elements[elementIndex].startIndex) cursorPos = elements[elementIndex].endIndex const element = elements[elementIndex] const insertIdx = cursorPos - element.startIndex element.content = element.content.slice(0, insertIdx) + text + element.content.slice(insertIdx) element.endIndex += text.length controller.updateFollowingIndices(elements, elementIndex) } /// \return cursor offset function deleteInContent(elements, elementIndex, cursorPos, deleteRightOfCursor = true) { const element = elements[elementIndex] var deleteIdx = -1 // Also marks content change var cursorOffset = 0 var startOfIndicesUpdate = elementIndex if(deleteRightOfCursor) { if (cursorPos === element.endIndex) { // If at the end of the content delete next separator and merge content if((elementIndex + 2) < elements.length && elements[elementIndex + 1].isSeparator() && !elements[elementIndex + 2].isFrozen) { const newContent = element.content + elements[elementIndex + 2].content elements.splice(elementIndex + 1, 2) element.content = newContent startOfIndicesUpdate = elementIndex } } else { deleteIdx = cursorPos - element.startIndex } } else { if (cursorPos === element.startIndex) { // If at content's beginning delete left separator and merge content var deletedChars = 0 if((elementIndex - 2) > 0 && elements[elementIndex - 1].isSeparator() && !elements[elementIndex - 2].isFrozen) { const newContent = elements[elementIndex - 2].content + element.content cursorOffset = -(elements[elementIndex - 1].content.length) elements.splice(elementIndex - 2, 2) element.content = newContent startOfIndicesUpdate = elementIndex - 2 } } else { deleteIdx = cursorPos - element.startIndex - 1 cursorOffset = -1 } } if(deleteIdx > -1) { element.content = element.content.slice(0, deleteIdx) + element.content.slice(deleteIdx + 1) element.endIndex -= 1 } controller.updateFollowingIndices(elements, startOfIndicesUpdate) return cursorOffset } function countNonEmptyLevels(elements) { var count = 0 for(var i = 0; i < (elements.length-1); i++) { if((elements[i].isSeparator() || elements[i].isBase()) && elements[i + 1].isNumber() && !elements[i + 1].isEmptyNumber()) { count++ } } return count } function normalizeAndRemoveDuplicateSeparators(elements, exceptIndex) { var currentContent = "" var markToDelete = [] for(var i = 0; i < (elements.length - 1); i++) { if(elements[i].isEmptyNumber() && elements[i+1].isSeparator() && i !== exceptIndex) { markToDelete.push(i) markToDelete.push(i + 1) } } // Cleanup the last separator if it is the case if(elements.length > 2 && elements[elements.length - 2].isSeparator() && elements[elements.length - 1].isEmptyNumber() && (elements.length - 1) !== exceptIndex) { markToDelete.push(elements.length - 2) markToDelete.push(elements.length - 1) } for (var i = markToDelete.length - 1; i >= 0; i--) { elements.splice(markToDelete[i], 1) } d.initializeReferenceElementsIfRequired() // Normalize separators for(var i = 0; i < elements.length; i++) { if(i < d.referenceElements.length && d.referenceElements[i].isSeparator()) { currentContent = d.referenceElements[i].content } // else: use the last separator if(elements[i].isSeparator() && currentContent.length > 0 && elements[i].content !== currentContent) { elements[i].content = currentContent } } updateFollowingIndices(elements, 0) } function updateFollowingIndices(elements, firstElementIndex) { for(var i = firstElementIndex; i < elements.length; i++) { if(i == 0) { elements[i].startIndex = 0 } else { elements[i].startIndex = elements[i - 1].endIndex } elements[i].endIndex = elements[i].startIndex + elements[i].content.length } } function findFirstEmptyContent(elements) { for(var i = 0; i < elements.length; i++) { if(elements[i].content.length === 0) { return i } } return -1 } function deleteFromStartToEnd(elements, startPos, endPos) { var startIndex = -1 var endIndex = -1 for(var i = 0; i < elements.length; i++) { if(startIndex == -1 && startPos >= elements[i].startIndex) { startIndex = i } else if(endIndex == -1 && endPos <= elements[i].startIndex) { endIndex = i } } elements.splice(startIndex, endIndex - startIndex) updateFollowingIndices(elements, 0) } /// \return the object {error: "", warning: ""} function validateAllElements(elements) { var numberLevel = 0 var warningMessage = "" for(var i = 0; i < elements.length; i++) { const res = validateElement(elements[i], elements[i].isNumber() ? numberLevel : -1) if(res.error.length > 0) { return res } else if(res.warning.length > 0 && warningMessage === "") { warningMessage = res.warning } if(elements[i].isNumber()) { numberLevel++ } } return {error: "", warning: warningMessage} } /// Expect -1 if not an number element /// \return the object {error: "", warning: ""} function validateElement(element, numberLevel) { if(numberLevel > -1 && !element.validateNumber() && !element.isEmptyNumber()) { return {error: root.inputError, warning: ""} } else if(numberLevel == 0 && !element.isEmptyNumber() && element.number() != d.ethereumCoinType) { return {error: "", warning: root.nonEthCoinWarning} } else if(root.complainTooBigAccIndex && numberLevel >= d.addressIndexStart && element.number() >= 100) { return {error: root.tooBigError, warning: ""} } return {error: "", warning: ""} } Component { id: elementComponent Element { content: "" startIndex: 0 endIndex: 0 contentType: 0 } } }