Add initial presets (#1)

Signed-off-by: Chris Kim <oats87g@gmail.com>
This commit is contained in:
Chris Kim
2025-03-31 19:47:05 -07:00
committed by GitHub
parent e0a9610d02
commit fd9f215f9d
18 changed files with 1899 additions and 0 deletions

52
indexer/IndexContent.js Normal file
View File

@@ -0,0 +1,52 @@
'use strict';
class IndexContent
{
constructor(presetFilesArray, settings)
{
this.majorVersion = 1;
this.minorVersion = 0;
this.settings = settings;
this.uniqueValues = {};
this.presets = presetFilesArray;
this.uniqueValues.firmware_version = this._getUniqueValues(presetFilesArray, "firmware_version");
this.uniqueValues.category = this._getUniqueValues(presetFilesArray, "category");
this.uniqueValues.author = this._getUniqueValues(presetFilesArray, "author");
this.uniqueValues.keywords = this._getUniqueValues(presetFilesArray, "keywords");
this.uniqueValues.board_name = this._getUniqueValues(presetFilesArray, "board_name");
}
_getUniqueValues(presetFilesArray, property)
{
let result = new Set();
let resultLowerCase = new Set();
function addValue(value) {
const valueLowCase = value.toLowerCase();
if (!resultLowerCase.has(valueLowCase)) {
result.add(value);
resultLowerCase.add(valueLowCase);
}
}
for (let preset of presetFilesArray) {
if (property in preset) {
if (Array.isArray(preset[property])) {
for (let value of preset[property]) {
addValue(value);
}
} else {
addValue(preset[property]);
}
}
}
result = [...result];
result.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}));
return result;
}
}
module.exports = IndexContent;

473
indexer/PresetsFile.js Normal file
View File

@@ -0,0 +1,473 @@
'use strict';
const readline = require('readline');
const fs = require("fs");
const crypto = require('crypto');
class PresetsFile
{
constructor(fullPath, settings, errors)
{
this.fullPath = fullPath;
this.hash = "";
this._presetsFileMetadata = settings.presetsFileMetadata;
this._errors = errors;
this._settings = settings;
this.options = [];
this.optionGroups = [];
this._currentOption = undefined;
this._currentOptionGroup = undefined;
const binaryFileContent = fs.readFileSync(this.fullPath);
let sum = crypto.createHash('sha256');
sum.update(binaryFileContent);
this.hash = sum.digest('hex');
this._processLines(binaryFileContent, settings.presetsFileEncoding);
this._checkProperties();
if (undefined === this.priority) {
this.priority = this._settings.PresetCategoriesPriorities[this.category];
}
this._clearProperties();
}
_clearProperties()
{
delete this._presetsFileMetadata;
delete this._errors;
delete this._settings;
delete this._currentOption;
delete this._currentOptionGroup;
delete this.options;
delete this.optionGroups;
delete this.description;
delete this.include;
delete this.discussion;
delete this.warning;
delete this.disclaimer;
delete this.include_warning;
delete this.include_disclaimer;
delete this.parser;
}
_checkProperties()
{
for (const [property, value] of Object.entries(this._presetsFileMetadata)) {
if (!value.optional) {
if (this._isEmptyProperty(this[property]))
{
this._addError(`missing or empty property '${property}'`);
}
}
}
if (undefined !== this._currentOption) {
this._addError(`Missing ${this._settings.OptionsDirectives.END_OPTION_DIRECTIVE} for ${this._currentOption.name}`);
}
if (undefined !== this._currentOptionGroup) {
this._addError(`Missing ${this._settings.OptionsDirectives.END_OPTION_GROUP_DIRECTIVE} for ${this._currentOptionGroup.name}`);
}
}
_isEmptyProperty(property)
{
return (property === undefined || property.length === 0);
}
_processLines(binaryFileContent, presetsFileEncoding)
{
const fileContent = binaryFileContent.toString(presetsFileEncoding);
const lines = fileContent.split('\n');
this._currentLine = 1;
for (let line of lines) {
line = line.trim();
if (line.startsWith(this._settings.MetapropertyDirective)) {
this._processMetapropertyLine(line);
}
this._currentLine++;
}
delete this._currentLine;
}
_processMetapropertyLine(line)
{
line = line.slice(this._settings.MetapropertyDirective.length).trim(); // (#$ Title: foo) -> (Title: foo)
const lowCaseLine = line.toLowerCase();
let isProperty = false;
let isPropertyMissingSemicolon = false;
let isOptionDirective = false;
for (const [property, value] of Object.entries(this._presetsFileMetadata)) {
const lineBeginning = `${property.toLowerCase()}:`; // "TITLE:"
const wrongLineBeginning = `${property.toLowerCase()}`; // "TITLE"
if (lowCaseLine.startsWith(lineBeginning)) {
line = line.slice(lineBeginning.length).trim(); // (Title: foo) -> (foo)
this._processProperty(property, line);
isProperty = true;
} else if (lowCaseLine.startsWith(wrongLineBeginning)) {
isPropertyMissingSemicolon = true;
}
}
if (!isProperty && lowCaseLine.startsWith(this._settings.OptionsDirectives.OPTION_DIRECTIVE)) {
this._processOptionDirective(line);
isOptionDirective = true;
}
if (!isProperty && !isOptionDirective) {
if (isPropertyMissingSemicolon) {
this._addError(`line ${this._currentLine}, property missing ":"`);
} else {
this._addError(`line ${this._currentLine}, unknown preset directive: '${line}'`);
}
}
}
_processOptionDirective(line)
{
const lowCaseLine = line.toLowerCase();
if (lowCaseLine.startsWith(this._settings.OptionsDirectives.BEGIN_OPTION_DIRECTIVE)) {
this._processOptionBeginDirective(line, lowCaseLine);
} else if (lowCaseLine.startsWith(this._settings.OptionsDirectives.END_OPTION_DIRECTIVE)) {
this._processOptionEndDirective(line, lowCaseLine);
} else if (lowCaseLine.startsWith(this._settings.OptionsDirectives.BEGIN_OPTION_GROUP_DIRECTIVE)) {
this._processOptionGroupBeginDirective(line, lowCaseLine);
} else if (lowCaseLine.startsWith(this._settings.OptionsDirectives.END_OPTION_GROUP_DIRECTIVE)) {
this._processOptionGroupEndDirective(line, lowCaseLine);
}
}
_processOptionGroupBeginDirective(line, lowCaseLine)
{
const optionGroup = this._getOptionGroup(line);
if ("" === optionGroup.name) {
this._addError(`line ${this._currentLine}, empty optionGroup name`);
} else if (undefined !== this._currentOptionGroup) {
this._addError(`line ${this._currentLine}, nested #$ option groups are not allowed`);
} else {
this._currentOptionGroup = optionGroup;
}
}
_processOptionGroupEndDirective(line, lowCaseLine)
{
if (undefined === this._currentOptionGroup) {
this._addError(`line ${this._currentLine}, end Option Group directive found but no Option Group to close`);
} else {
const lowCaseOptionGroupName = this._currentOptionGroup.name.toLowerCase();
const indexOfOption = this.optionGroups.findIndex(item => lowCaseOptionGroupName === item.name.toLowerCase());
if (-1 === indexOfOption) {
this.optionGroups.push(this._currentOptionGroup);
}
this._currentOptionGroup = undefined;
}
}
_processOptionBeginDirective(line, lowCaseLine)
{
const Option = this._getOption(line);
const lowCaseOptionName = Option.name.toLowerCase();
if ("" === Option.name) {
this._addError(`line ${this._currentLine}, empty Option name`);
} else if (undefined !== this._currentOption) {
this._addError(`line ${this._currentLine}, nested #$ options are not allowed`);
} else {
this._currentOption = Option;
}
}
_processOptionEndDirective(line, lowCaseLine)
{
if (undefined === this._currentOption) {
this._addError(`line ${this._currentLine}, end Option directive found but no Option to close`);
} else {
const lowCaseOptionName = this._currentOption.name.toLowerCase();
const indexOfOption = this.options.findIndex(item => lowCaseOptionName === item.name.toLowerCase());
if (-1 === indexOfOption) {
this.options.push(this._currentOption);
}
this._currentOption = undefined;
}
}
_escapeRegex(string)
{
return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
_getOptionGroup(line)
{
const directiveRemoved = line.slice(this._settings.OptionsDirectives.BEGIN_OPTION_GROUP_DIRECTIVE.length).trim();
const isExclusiveGroup = this._isExclusiveGroup(directiveRemoved.toLowerCase());
const exclusiveOptionGroupRegex = new RegExp(this._escapeRegex(this._settings.OptionsDirectives.EXCLUSIVE_OPTION_GROUP), 'gi');
const optionGroupName = directiveRemoved.replace(exclusiveOptionGroupRegex, "");
if (0 == optionGroupName.length || optionGroupName[0] != ":") {
this._addError(`line ${this._currentLine}, OPTION_GROUP BEGIN directive should be followed by ":". Example: #$ OPTION_GROUP BEGIN: My Group Name or if its exclusive: #$ OPTION_GROUP BEGIN: (EXCLUSIVE) My Exclusive Group`);
}
let optionGroup = {
name: optionGroupName.slice(1).trim(),
exclusive: isExclusiveGroup,
}
return optionGroup;
}
_isExclusiveGroup(lowercaseLine)
{
return lowercaseLine.includes(this._settings.OptionsDirectives.EXCLUSIVE_OPTION_GROUP)
}
_getOption(line)
{
const directiveRemoved = line.slice(this._settings.OptionsDirectives.BEGIN_OPTION_DIRECTIVE.length).trim();
const directiveRemovedLowCase = directiveRemoved.toLowerCase();
const optionChecked = this._isOptionChecked(directiveRemovedLowCase);
const regExpRemoveChecked = new RegExp(this._escapeRegex(this._settings.OptionsDirectives.OPTION_CHECKED), 'gi');
const regExpRemoveUnchecked = new RegExp(this._escapeRegex(this._settings.OptionsDirectives.OPTION_UNCHECKED), 'gi');
let optionName = directiveRemoved.replace(regExpRemoveChecked, "");
optionName = optionName.replace(regExpRemoveUnchecked, "").trim();
if (0 == optionName.length || optionName[0] != ":") {
this._addError(`line ${this._currentLine}, OPTION BEGIN directive should be followed by ":". Example: #$ OPTION BEGIN (UNCHECKED): My Option Name`);
}
let option = {
name: optionName.slice(1).trim(),
checked: optionChecked
}
return option;
}
_isOptionChecked(lowCaseLine)
{
let OptionChecked = false;
let OptionUnchecked = false;
if (lowCaseLine.includes(this._settings.OptionsDirectives.OPTION_CHECKED)) {
OptionChecked = true;
}
if (lowCaseLine.includes(this._settings.OptionsDirectives.OPTION_UNCHECKED)) {
OptionUnchecked = true;
}
if (OptionChecked && OptionUnchecked) {
this._addError(`line ${this._currentLine}, Option can't be checked and unchecked at the same time`);
} else if (!OptionChecked && !OptionUnchecked) {
this._addError(`line ${this._currentLine}, Every option must specify whether it is ${this._settings.OptionsDirectives.OPTION_CHECKED.toUpperCase()} or ${this._settings.OptionsDirectives.OPTION_UNCHECKED.toUpperCase()}`);
} else {
OptionChecked = OptionChecked;
}
return OptionChecked;
}
_processProperty(property, line)
{
switch(this._presetsFileMetadata[property].type) {
case this._settings.MetadataTypes.STRING_ARRAY:
this._processArrayProperty(property, line);
break;
case this._settings.MetadataTypes.STRING:
this._processStringProperty(property, line);
break;
case this._settings.MetadataTypes.PRESET_CATEGORY:
this._processPresetCategoryProperty(property, line);
break;
case this._settings.MetadataTypes.FILE_PATH:
this._processFilePathProperty(property, line);
break;
case this._settings.MetadataTypes.FILE_PATH_ARRAY:
this._processFilePathArrayProperty(property, line);
break;
case this._settings.MetadataTypes.BOOLEAN:
this._processBooleanProperty(property, line);
break;
case this._settings.MetadataTypes.WORDS_ARRAY:
this._processWordsArrayProperty(property, line);
break;
case this._settings.MetadataTypes.PRESET_STATUS:
this._processPresetStatusProperty(property, line);
break;
case this._settings.MetadataTypes.PRIORITY:
this._processPriorityProperty(property, line);
break;
case this._settings.MetadataTypes.PARSER:
this._processParserProperty(property, line);
break;
default:
this._addError(`line ${this._currentLine}, unknown property type '${this._presetsFileMetadata[property].type}' for the property '${property}'`);
}
}
_processPresetStatusProperty(property, line)
{
this._checkPropertyDublicated(property);
if (this._settings.PresetStatusEnum.includes(line)) {
this[property] = line;
} else {
this._addError(`line ${this._currentLine}, unknown ${property} value: '${line}'; available values: ${this._settings.PresetStatusEnum}`);
}
}
_processParserProperty(property, line)
{
this._checkPropertyDublicated(property);
if (this._settings.ParserEnum.includes(line)) {
this[property] = line;
} else {
this._addError(`line ${this._currentLine}, unknown ${property} value: '${line}'; available values: ${this._settings.ParserEnum}`);
}
}
_processWordsArrayProperty(property, line)
{
this._checkPropertyDublicated(property);
let words = line.split(",");
words = words.map(word => word.trim());
words = words.filter(word => word);
this[property] = words;
}
_processBooleanProperty(property, line)
{
this._checkPropertyDublicated(property);
const trueValues = ["true", "yes"];
const falseValues = ["false", "no"];
const lineLowCase = line.toLowerCase();
let result = false;
if (trueValues.includes(lineLowCase)) {
result = true;
} else if (falseValues.includes(lineLowCase)) {
result = false;
} else {
this._addError(`line ${this._currentLine}, boolean property '${property}'' has a wrong value: '${line}'`);
}
this[property] = result;
}
_checkPropertyDublicated(property)
{
if (undefined !== this[property]) {
this._addError(`line ${this._currentLine}, duplicated property '${property}'`);
}
}
_processFilePathProperty(property, line)
{
this._checkPropertyDublicated(property);
const stat = fs.statSync(line);
if (!stat || stat.isDirectory()) {
this._addError(`line ${this._currentLine}, can't find file '${line}'`);
} else {
this[property] = line;
}
}
_processFilePathArrayProperty(property, line)
{
if (!this[property]) {
this[property] = [];
}
if (fs.existsSync(line)) {
// still could be a folder, so have to filter out folders
const stat = fs.statSync(line);
if (!stat || stat.isDirectory()) {
this._addError(`line ${this._currentLine}, a folder is specified instead of a file: '${line}'`);
} else {
this[property].push(line);
}
} else {
this._addError(`line ${this._currentLine}, can't find file '${line}'`);
}
}
_processPresetCategoryProperty(property, line)
{
this._checkPropertyDublicated(property);
line = line.toLowerCase();
let presetTypeValid = false;
for (const [key, value] of Object.entries(this._settings.PresetCategories)) {
if (key.toLowerCase() === line) {
presetTypeValid = true;
this[property] = key;
}
}
if (!presetTypeValid) {
this._addError(`line ${this._currentLine}, unknown preset category: '${line}'`);
}
}
_processArrayProperty(property, line)
{
if (!this[property]) {
this[property] = [];
}
this[property].push(line);
}
_processStringProperty(property, line)
{
this._checkPropertyDublicated(property);
this[property] = line;
}
_processPriorityProperty(property, line)
{
this._checkPropertyDublicated(property);
const value = parseInt(line);
const value2 = Number(line);
if (NaN === value || value !== value2) {
this._addError(`line ${this._currentLine}, PRIORITY value must be integer. Instead it is: '${line}'`);
}
this[property] = value;
}
_addError(error)
{
const fullError = `${this.fullPath}: ${error}`;
this._errors.push(fullError);
console.error(fullError);
}
}
module.exports = PresetsFile;

79
indexer/PresetsFolder.js Normal file
View File

@@ -0,0 +1,79 @@
'use strict';
const fs = require("fs");
const path = require("path");
const PresetsFile = require('./PresetsFile');
class PresetsFolder
{
constructor(fullPath, settings, presetFilesArray, errors)
{
this.subFolders = [];
this.files = [];
this.fullPath = fullPath;
this.name = "";
let list = fs.readdirSync(fullPath);
list.forEach((fileName) => {
const fullFileName = fullPath + '/' + fileName;
const stat = fs.statSync(fullFileName);
if (!fileName.startsWith(".")) {
if (stat && stat.isDirectory()) {
let subdir = new PresetsFolder(fullFileName, settings, presetFilesArray, errors);
subdir.name = fileName;
this.subFolders.push(subdir);
} else if (fileName.toLowerCase().endsWith(".txt")) {
let presetsFile = new PresetsFile(fullFileName, settings, errors);
presetFilesArray.push(presetsFile);
this.files.push(presetsFile);
}
}
});
}
static checkForIncludeLoops(presetFilesArray, errors)
{
const filesDb = PresetsFolder._createFilesDictionary(presetFilesArray);
const fileNameErrors = {};
presetFilesArray.forEach((file) => {
const parents = [];
PresetsFolder._checkFileForIncludeLoops(file, filesDb, parents, fileNameErrors);
});
for (const fileNameError in fileNameErrors) {
const errorText = `File ${fileNameError}, takes part in the #$ INCLUDE loop'`;
errors.push(errorText);
console.error(errorText);
}
}
static _checkFileForIncludeLoops(file, filesDb, parents, fileNameErrors)
{
if (parents.includes(file.fullPath)) {
fileNameErrors[file.fullPath] = true;
} else {
parents.push(file.fullPath);
if (file.hasOwnProperty("include")) {
file.include.forEach((includedFileName) => {
const clonedParents = [...parents];
PresetsFolder._checkFileForIncludeLoops(filesDb[includedFileName], filesDb, clonedParents, fileNameErrors);
});
}
}
}
static _createFilesDictionary(presetFilesArray)
{
let result = {};
presetFilesArray.forEach((file) => {
result[file.fullPath] = file;
});
return result;
}
}
module.exports = PresetsFolder;

86
indexer/Settings.js Normal file
View File

@@ -0,0 +1,86 @@
'use strict';
const MetadataTypes = {
STRING: "STRING",
STRING_ARRAY: "STRING_ARRAY", // strings from multiple comment lines
PRESET_CATEGORY: "PRESET_CATEGORY", // TUNE/RATES/OSD etc
FILE_PATH: "FILE_PATH", // path/to/file.ext and check if file exists
BOOLEAN: "BOOLEAN", // true/false
WORDS_ARRAY: "WORDS_ARRAY", // "word1, word2, word3"
FILE_PATH_ARRAY: "FILE_PATH_ARRAY", // array of path/to/file.ext and check if files exist
PRESET_STATUS: "PRESET_STATUS", // official/community/experimental
PRIORITY: "PRIORITY", // 0..99
PARSER: "PARSER", // TEXT, MARKED
}
const PresetStatusEnum = ["OFFICIAL", "COMMUNITY", "EXPERIMENTAL"];
const ParserEnum = ["TEXT", "MARKED"];
const PresetCategories = {
PROFILE: "PROFILE",
RATEPROFILE: "RATEPROFILE",
FILTERS: "FILTERS",
REMAPPING: "REMAPPING",
BNF: "BNF",
OTHER: "OTHER",
}
const PresetCategoriesPriorities = {
PROFILE: 10**12,
RATEPROFILE: 10**4,
REMAPPING: 0,
FILTERS: 10**2,
BNF: 0,
OTHER: 0,
}
const OptionsDirectives = {
OPTION_DIRECTIVE: "option",
BEGIN_OPTION_DIRECTIVE: "option begin",
END_OPTION_DIRECTIVE: "option end",
OPTION_CHECKED: "(checked)",
OPTION_UNCHECKED: "(unchecked)",
BEGIN_OPTION_GROUP_DIRECTIVE: "option_group begin",
END_OPTION_GROUP_DIRECTIVE: "option_group end",
EXCLUSIVE_OPTION_GROUP: "(exclusive)",
}
const settings = {
MetapropertyDirective: "#$",
PresetCategories: Object.freeze(PresetCategories),
PresetCategoriesPriorities: Object.freeze(PresetCategoriesPriorities),
MetadataTypes: Object.freeze(MetadataTypes),
OptionsDirectives : Object.freeze(OptionsDirectives),
PresetStatusEnum : Object.freeze(PresetStatusEnum),
ParserEnum : Object.freeze(ParserEnum),
presetsDir: "presets",
presetsFileEncoding: "utf-8",
presetsFileMetadata: Object.freeze({
title: {type: MetadataTypes.STRING, optional: false },
firmware_version: {type: MetadataTypes.STRING_ARRAY, optional: false },
category: {type: MetadataTypes.PRESET_CATEGORY, optional: false },
status: {type: MetadataTypes.PRESET_STATUS, optional: false },
board_name: {type: MetadataTypes.STRING_ARRAY, optional: true },
author: {type: MetadataTypes.STRING, optional: true },
description: {type: MetadataTypes.STRING_ARRAY, optional: true },
include: {type: MetadataTypes.FILE_PATH_ARRAY, optional: true },
keywords: {type: MetadataTypes.WORDS_ARRAY, optional: true },
hidden: {type: MetadataTypes.BOOLEAN, optional: true },
discussion: {type: MetadataTypes.STRING, optional: true },
warning: {type: MetadataTypes.STRING, optional: true },
disclaimer: {type: MetadataTypes.STRING, optional: true },
include_warning: {type: MetadataTypes.FILE_PATH_ARRAY, optional: true },
include_disclaimer: {type: MetadataTypes.FILE_PATH_ARRAY, optional: true },
priority: {type: MetadataTypes.PRIORITY, optional: true },
force_options_review: {type: MetadataTypes.BOOLEAN, optional: true },
parser: {type: MetadataTypes.PARSER, optional: true },
}),
}
module.exports = Object.freeze(settings);

6
indexer/check.js Normal file
View File

@@ -0,0 +1,6 @@
'use strict';
const child = require('child_process').fork('indexer/indexer.js', ["nosave"]);
child.on('exit', (code) => {
process.exitCode = code;
});

45
indexer/indexer.js Normal file
View File

@@ -0,0 +1,45 @@
'use strict';
const fs = require("fs");
const path = require("path");
const PresetsFolder = require('./PresetsFolder');
const settings = require('./Settings');
const IndexContent = require('./IndexContent');
const crypto = require('crypto');
const errors = [];
process.exitCode = 100;
let writeIndexFile = true;
if (process.argv.length === 3) {
if (process.argv[2] === "nosave") {
writeIndexFile = false;
}
}
let presetFilesArray = [];
let presetsFolder = new PresetsFolder(settings.presetsDir, settings, presetFilesArray, errors);
PresetsFolder.checkForIncludeLoops(presetFilesArray, errors);
//console.log(getUniqueValues(presetFilesArray, "firmwareVersion"));
if (0 === errors.length) {
console.log("OK");
if (writeIndexFile) {
const indexContent = new IndexContent(presetFilesArray, settings);
const jsonIndexContent = JSON.stringify(indexContent, null, 2);
fs.writeFileSync("index.json", jsonIndexContent);
console.log("index.json created");
const sum = crypto.createHash('sha256');
sum.update(jsonIndexContent);
const indexHash = sum.digest('hex');
fs.writeFileSync("index_hash.txt", indexHash);
console.log("index_hash.txt created");
}
process.exitCode = 0;
} else {
console.log("Failed with errors");
}