diff --git a/companion/src/firmwares/radiodata.cpp b/companion/src/firmwares/radiodata.cpp index f027e7439..cc3d319d1 100644 --- a/companion/src/firmwares/radiodata.cpp +++ b/companion/src/firmwares/radiodata.cpp @@ -230,7 +230,7 @@ bool RadioData::addLabelToModel(int index, QString label) auto & modelLabelCsv = models[index].labels; // Make sure it will fit - if (modelLabelCsv.size() + (size_t)label.size() + 1 < modelLabelCsv.capacity()) { + if (modelLabelCsv.size() + (size_t)label.size() + 1 <= modelLabelCsv.capacity()) { QStringList modelLabels = QString::fromLatin1(modelLabelCsv.c_str()).split(',', Qt::SkipEmptyParts); if (modelLabels.indexOf(label) == -1) { modelLabels.append(label); diff --git a/radio/src/dataconstants.h b/radio/src/dataconstants.h index c956fc329..b88d73c4b 100644 --- a/radio/src/dataconstants.h +++ b/radio/src/dataconstants.h @@ -37,7 +37,7 @@ #define LUA_EXPORT_EXTRA(...) #endif -#define LABELS_LENGTH 100 // Maximum length of the label string +#define LABELS_LENGTH 99 // Maximum length of the label string #define LABEL_LENGTH 16 #if defined(COLORLCD) diff --git a/radio/src/datastructs_private.h b/radio/src/datastructs_private.h index 2190779c9..a565cf771 100644 --- a/radio/src/datastructs_private.h +++ b/radio/src/datastructs_private.h @@ -608,7 +608,7 @@ PACK(struct ModelHeader { uint8_t modelId[NUM_MODULES]; MODEL_HEADER_BITMAP_FIELD #if defined(STORAGE_MODELSLIST) - char labels[LABELS_LENGTH]; + char labels[LABELS_LENGTH + 1]; // alow null terminator #endif }); diff --git a/radio/src/gui/128x64/model_setup.cpp b/radio/src/gui/128x64/model_setup.cpp index 032ddb5f5..c64856ab6 100644 --- a/radio/src/gui/128x64/model_setup.cpp +++ b/radio/src/gui/128x64/model_setup.cpp @@ -2039,11 +2039,7 @@ void menuModelSetup(event_t event) else if (event == EVT_KEY_LONG(KEY_ENTER)) { killEvents(event); uint8_t newVal = 0; -#if defined(STORAGE_MODELSLIST) - newVal = modelslist.findNextUnusedModelId(moduleIdx); -#else newVal = findNextUnusedModelId(g_eeGeneral.currModel, moduleIdx); -#endif if (newVal != g_model.header.modelId[moduleIdx]) { modelHeaders[g_eeGeneral.currModel].modelId[moduleIdx] = g_model.header.modelId[moduleIdx] = newVal; storageDirty(EE_MODEL); diff --git a/radio/src/gui/colorlcd/libui/dialog.cpp b/radio/src/gui/colorlcd/libui/dialog.cpp index 8e594ad13..76cd5ada6 100644 --- a/radio/src/gui/colorlcd/libui/dialog.cpp +++ b/radio/src/gui/colorlcd/libui/dialog.cpp @@ -173,7 +173,8 @@ void ConfirmDialog::onCancel() //----------------------------------------------------------------------------- LabelDialog::LabelDialog(const char *label, int length, const char* title, - std::function _saveHandler) : + std::function _saveHandler, + const char* excludedChars) : ModalWindow(false), saveHandler(std::move(_saveHandler)) { strncpy(this->label, label, std::min(length, MAX_LABEL_LENGTH)); @@ -197,7 +198,9 @@ LabelDialog::LabelDialog(const char *label, int length, const char* title, lv_obj_set_flex_align(box->getLvObj(), LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_SPACE_BETWEEN); - new TextEdit(box, rect_t{0, 0, LV_PCT(100), 0}, this->label, length); + auto edit = new TextEdit(box, rect_t{0, 0, LV_PCT(100), 0}, this->label, length); + if (excludedChars) + edit->setExcludedCharacters(excludedChars); box = new Window(form, rect_t{}); box->padAll(PAD_MEDIUM); diff --git a/radio/src/gui/colorlcd/libui/dialog.h b/radio/src/gui/colorlcd/libui/dialog.h index 46b2fc5f2..0568f8ab6 100644 --- a/radio/src/gui/colorlcd/libui/dialog.h +++ b/radio/src/gui/colorlcd/libui/dialog.h @@ -134,7 +134,8 @@ class LabelDialog : public ModalWindow { public: LabelDialog(const char *label, int length, const char* title, - std::function _saveHandler = nullptr); + std::function _saveHandler = nullptr, + const char* excludedChars = nullptr); static constexpr int MAX_LABEL_LENGTH = 255; diff --git a/radio/src/gui/colorlcd/libui/numberedit.h b/radio/src/gui/colorlcd/libui/numberedit.h index 6a8793660..0e21817f9 100644 --- a/radio/src/gui/colorlcd/libui/numberedit.h +++ b/radio/src/gui/colorlcd/libui/numberedit.h @@ -119,8 +119,8 @@ class NumberEdit : public TextButton std::string prefix; std::string suffix; std::string zeroText; - std::function displayFunction; - std::function isValueAvailable; + std::function displayFunction = nullptr; + std::function isValueAvailable = nullptr; std::string getDisplayVal(); diff --git a/radio/src/gui/colorlcd/libui/textedit.cpp b/radio/src/gui/colorlcd/libui/textedit.cpp index 860ae0352..d759892bb 100644 --- a/radio/src/gui/colorlcd/libui/textedit.cpp +++ b/radio/src/gui/colorlcd/libui/textedit.cpp @@ -74,10 +74,21 @@ class TextArea : public FormField cancelHandler = std::move(handler); } + void setExcludedChars(std::string& excludedChars) { + if (excludedChars.size() > 0) { + for (char c = ' '; c <= '~'; c += 1) { + if (excludedChars.find(c) == std::string::npos) + allowedChars += c; + } + lv_textarea_set_accepted_chars(lvobj, allowedChars.c_str()); + } + } + protected: char* value; uint8_t length; std::function cancelHandler = nullptr; + std::string allowedChars; void trim() { @@ -165,6 +176,8 @@ void TextEdit::openEdit() lv_group_focus_obj(lvobj); edit->hide(); }); + if (excludedChars.size() > 0) + edit->setExcludedChars(excludedChars); } edit->update(); edit->show(); diff --git a/radio/src/gui/colorlcd/libui/textedit.h b/radio/src/gui/colorlcd/libui/textedit.h index 937d9733c..90226e86f 100644 --- a/radio/src/gui/colorlcd/libui/textedit.h +++ b/radio/src/gui/colorlcd/libui/textedit.h @@ -36,8 +36,11 @@ class TextEdit : public TextButton void preview(bool edited, char* text, uint8_t length); void update(); + void setExcludedCharacters(const char* chars) { excludedChars = chars; } + protected: std::function updateHandler = nullptr; + std::string excludedChars = ""; TextArea* edit = nullptr; char* text; uint8_t length; diff --git a/radio/src/gui/colorlcd/model/model_select.cpp b/radio/src/gui/colorlcd/model/model_select.cpp index c9db03ac4..640281d51 100644 --- a/radio/src/gui/colorlcd/model/model_select.cpp +++ b/radio/src/gui/colorlcd/model/model_select.cpp @@ -98,7 +98,7 @@ class ModelButton : public Button } lv_label_set_long_mode(modelName->getLvObj(), LV_LABEL_LONG_DOT); - bool chk = (modelCell == modelslist.getCurrentModel()); + bool chk = (modelCell == modelCellManager.getCurrentModel()); if (chk != checked()) { check(chk); if (chk) @@ -197,15 +197,15 @@ class ModelsPageBody : public Window ModelsVector models; if (selectedLabels.size()) { - models = modelslabels.getModelsInLabels(selectedLabels); + models = modelCellManager.getFilteredModelList(selectedLabels); } else { - models = modelslabels.getAllModels(); + models = modelCellManager.getAllModels(); } // Used to work out which button to set focus to. // Priority - - // current active model // previously selected model + // current active model // first model in the list ModelButton *firstButton = nullptr; ModelButton *focusedButton = nullptr; @@ -238,8 +238,8 @@ class ModelsPageBody : public Window } if (!firstButton) firstButton = button; - if (model == modelslist.getCurrentModel()) focusedButton = button; - if (model == focusedModel && !focusedButton) focusedButton = button; + if (model == focusedModel) focusedButton = button; + if (model == modelCellManager.getCurrentModel() && !focusedButton) focusedButton = button; // Press Handler for Models button->setPressHandler([=]() -> uint8_t { @@ -251,7 +251,7 @@ class ModelsPageBody : public Window } else { focusedModel = model; } - return model == modelslist.getCurrentModel(); + return model == modelCellManager.getCurrentModel(); }); // Long Press Handler for Models @@ -261,7 +261,7 @@ class ModelsPageBody : public Window focusedModel = model; } openMenu(); - return model == modelslist.getCurrentModel(); + return model == modelCellManager.getCurrentModel(); }); } @@ -293,11 +293,11 @@ class ModelsPageBody : public Window inline void setSortOrder(ModelsSortBy sortOrder) { - modelslabels.setSortOrder(sortOrder); + modelCellManager.setSortOrder(sortOrder); update(); } - ModelsSortBy getSortOrder() const { return modelslabels.sortOrder(); } + ModelsSortBy getSortOrder() const { return modelCellManager.sortOrder(); } void setLblRefreshFunc(std::function fnc) { @@ -305,7 +305,6 @@ class ModelsPageBody : public Window } protected: - ModelsSortBy _sortOrder; bool isDirty = false; bool refresh = false; std::string selectedLabel; @@ -328,13 +327,13 @@ class ModelsPageBody : public Window Menu *menu = new Menu(); menu->setTitle(focusedModel->modelName); if (g_eeGeneral.modelQuickSelect || - focusedModel != modelslist.getCurrentModel()) { + focusedModel != modelCellManager.getCurrentModel()) { menu->addLine(STR_SELECT_MODEL, [=]() { selectModel(focusedModel); }); } menu->addLine(STR_DUPLICATE_MODEL, [=]() { duplicateModel(focusedModel); }); menu->addLine(STR_LABEL_MODEL, [=]() { editLabels(focusedModel); }); menu->addLine(STR_SAVE_TEMPLATE, [=]() { saveAsTemplate(focusedModel); }); - if (focusedModel != modelslist.getCurrentModel()) { + if (focusedModel != modelCellManager.getCurrentModel()) { menu->addLine(STR_DELETE_MODEL, [=]() { deleteModel(focusedModel); }); } } @@ -343,7 +342,7 @@ class ModelsPageBody : public Window { // Don't need to check connection to receiver if re-selecting the active // model - if (model != modelslist.getCurrentModel()) { + if (model != modelCellManager.getCurrentModel()) { bool modelConnected = TELEMETRY_STREAMING() && !g_eeGeneral.disableRssiPoweroffAlarm; if (modelConnected) { @@ -364,7 +363,7 @@ class ModelsPageBody : public Window closeHandler(); // Skip reloading model if re-selecting the active model - if (model != modelslist.getCurrentModel()) { + if (model != modelCellManager.getCurrentModel()) { // store changes (if any) and load selected model storageFlushCurrentModel(); storageCheck(true); @@ -382,7 +381,7 @@ class ModelsPageBody : public Window LayoutFactory::deleteTopBarWidgets(); loadModel(g_eeGeneral.currModelFilename, true); - modelslist.setCurrentModel(model); + modelCellManager.setCurrentModel(model); // Load new main view layout LayoutFactory::loadCustomScreens(); @@ -413,10 +412,12 @@ class ModelsPageBody : public Window // Make a new model which is a copy of the selected one, set the // same labels auto new_model = - modelslist.addModel(duplicatedFilename, true, model); - for (const auto &lbl : modelslabels.getLabelsByModel(model)) { - modelslabels.addLabelToModel(lbl, new_model); - } + modelCellManager.addModel(duplicatedFilename, true, model); + // Give the copy a unique name and save it to disk + new_model->setUniqueName(); + new_model->updateModelFile(); + // Set new model as focused button + focusedModel = new_model; update(); } else { TRACE("ModelsListError: Invalid File"); @@ -429,7 +430,7 @@ class ModelsPageBody : public Window new ConfirmDialog( STR_DELETE_MODEL, std::string(model->modelName, sizeof(model->modelName)).c_str(), [=] { - modelslist.removeModel(model); + modelCellManager.removeModel(model); if (refreshLabels != nullptr) refreshLabels(); update(); @@ -438,7 +439,7 @@ class ModelsPageBody : public Window void editLabels(ModelCell *model) { - auto labels = modelslabels.getLabels(); + auto labels = modelCellManager.getLabels(); // dont display menu if there will be no labels if (labels.size()) { @@ -451,18 +452,18 @@ class ModelsPageBody : public Window } }); - for (auto &label : modelslabels.getLabels()) { + for (auto &label : modelCellManager.getLabels()) { menu->addLineBuffered( label, [=]() { - if (!modelslabels.isLabelSelected(label, model)) - modelslabels.addLabelToModel(label, model, true); + if (!model->hasLabel(label)) + modelCellManager.addLabelToModel(label, model); else - modelslabels.removeLabelFromModel(label, model, true); + modelCellManager.removeLabelFromModel(label, model); isDirty = true; if (refreshLabels != nullptr) refreshLabels(); }, - [=]() { return modelslabels.isLabelSelected(label, model); }); + [=]() { return model->hasLabel(label); }); } menu->updateLines(); } @@ -537,9 +538,9 @@ ModelLabelsWindow::ModelLabelsWindow() : Page(ICON_MODEL_SELECT, PAD_ZERO, true) buildBody(body); // find the first label of the current model and make that label active - auto currentModel = modelslist.getCurrentModel(); + auto currentModel = modelCellManager.getCurrentModel(); if (currentModel != nullptr) { - auto modelLabels = modelslabels.getLabelsByModel(currentModel); + auto modelLabels = currentModel->getLabels(); if (modelLabels.size() > 0) { auto allLabels = getLabels(); auto found = @@ -613,8 +614,8 @@ void ModelLabelsWindow::newModel() new SelectTemplateFolder([=](std::string folder, std::string name) { // Create a new blank ModelCell and activate it first, createmodel() will // modify the model in memory. - auto newCell = modelslist.addModel("", false); - modelslist.setCurrentModel(newCell); + auto newCell = modelCellManager.addModel("", false); + modelCellManager.setCurrentModel(newCell); // Make the new model createModel(); @@ -638,7 +639,7 @@ void ModelLabelsWindow::newModel() storageCheck(true); // Update the current cell's data - modelslist.updateCurrentModelCell(); + modelCellManager.updateCurrentModelCell(); #if defined(LUA) // If there is a wizard Lua script, fire it up @@ -661,12 +662,12 @@ void ModelLabelsWindow::newLabel() { tmpLabel[0] = '\0'; new LabelDialog(tmpLabel, LABEL_LENGTH, STR_ENTER_LABEL, [=](std::string label) { - int newlabindex = modelslabels.addLabel(label); + int newlabindex = modelCellManager.addLabel(label); if (newlabindex >= 0) { auto labels = getLabels(); lblselector->setNames(labels); } - }); + }, labelExcludedChars); } void ModelLabelsWindow::buildHead(Window *hdr) @@ -747,14 +748,14 @@ void ModelLabelsWindow::buildBody(Window *window) }); #endif - std::set filteredLabels = modelslabels.filteredLabels(); + std::set filteredLabels = modelCellManager.filteredLabels(); if (g_eeGeneral.labelSingleSelect == 0) { lblselector->setMultiSelect(true); lblselector->setSelected(filteredLabels); lblselector->setMultiSelectHandler([=](std::set selected, std::set oldselection) { - if (modelslabels.getUnlabeledModels().size() != 0) { + if (modelCellManager.getUnlabeledModels().size() != 0) { // Special case for mutually exclusive Unsorted bool unsrt_is_selected = selected.find(lblselector->getRowCount() - 1) != selected.end(); @@ -831,7 +832,7 @@ void ModelLabelsWindow::buildBody(Window *window) if (newLabel.size() > 0) { auto rndialog = new ProgressDialog(STR_RENAME_LABEL, [=]() {}); - modelslabels.renameLabel( + modelCellManager.renameLabel( oldLabel, newLabel, [=](const char *name, int percentage) { rndialog->setTitle(std::string(STR_RENAME_LABEL) + " " + name); @@ -840,8 +841,7 @@ void ModelLabelsWindow::buildBody(Window *window) }); auto labels = getLabels(); lblselector->setNames(labels); - mdlselector->clearButtons(); - updateFilteredLabels(modelslabels.filteredLabels(), false); + updateFilteredLabels(modelCellManager.filteredLabels(), false); } }); return 0; @@ -852,7 +852,7 @@ void ModelLabelsWindow::buildBody(Window *window) STR_DELETE_LABEL, labelToDelete.c_str(), [=]() { auto deldialog = new ProgressDialog(STR_DELETE_LABEL, [=]() {}); - modelslabels.removeLabel( + modelCellManager.removeLabel( labelToDelete, [=](const char *name, int percentage) { deldialog->setTitle(std::string(STR_DELETE_LABEL) + " " + name); @@ -869,19 +869,18 @@ void ModelLabelsWindow::buildBody(Window *window) else newset.insert(lblselector->getActiveItem()); } - mdlselector->clearButtons(); updateFilteredLabels(newset); }); return 0; }); - if (modelslabels.getLabels().size() > 1) { + if (modelCellManager.getLabels().size() > 1) { if (selected != 0) { menu->addLine(STR_MOVE_UP, [=]() { moveLabel(selected, -1); return 0; }); } - if (selected != (int)modelslabels.getLabels().size() - 1) { + if (selected != (int)modelCellManager.getLabels().size() - 1) { menu->addLine(STR_MOVE_DOWN, [=]() { moveLabel(selected, 1); return 0; @@ -897,7 +896,7 @@ void ModelLabelsWindow::moveLabel(int selected, int direction) { int swapSelected = selected + direction; - modelslabels.moveLabelTo(selected, swapSelected); + modelCellManager.moveLabelUp(selected > swapSelected ? selected : swapSelected); std::set newset = lblselector->getSelection(); bool isSelected = newset.find(selected) != newset.end(); @@ -937,10 +936,8 @@ void ModelLabelsWindow::updateFilteredLabels(std::set selected, for (auto sel : selected) { if (sel < labels.size()) sellabels.push_back(labels[sel]); } - if (setdirty) { // Save to file? - modelslabels.setFilteredLabels(selected); - modelslabels.setDirty(); - } + if (setdirty) // Save to file? + modelCellManager.setFilteredLabels(selected); mdlselector->setLabels(sellabels); // Update the list } @@ -952,7 +949,7 @@ void ModelLabelsWindow::labelRefreshRequest() void ModelLabelsWindow::setTitle() { - auto curModel = modelslist.getCurrentModel(); + auto curModel = modelCellManager.getCurrentModel(); auto modelName = curModel != nullptr ? curModel->modelName : STR_NONE; std::string title2 = STR_ACTIVE; diff --git a/radio/src/gui/colorlcd/model/model_select.h b/radio/src/gui/colorlcd/model/model_select.h index 5c182118e..54e54f882 100644 --- a/radio/src/gui/colorlcd/model/model_select.h +++ b/radio/src/gui/colorlcd/model/model_select.h @@ -58,8 +58,8 @@ class ModelLabelsWindow : public Page LabelsVector getLabels() { - auto labels = modelslabels.getLabels(); - if (modelslabels.getUnlabeledModels().size() > 0) + auto labels = modelCellManager.getLabels(); + if (modelCellManager.getUnlabeledModels().size() > 0) labels.emplace_back(STR_UNLABELEDMODEL); return labels; } diff --git a/radio/src/gui/colorlcd/model/model_setup.cpp b/radio/src/gui/colorlcd/model/model_setup.cpp index 7317b9090..6b866a335 100644 --- a/radio/src/gui/colorlcd/model/model_setup.cpp +++ b/radio/src/gui/colorlcd/model/model_setup.cpp @@ -391,7 +391,7 @@ const static SetupLineDef setupLines[] = { new ModelTextEdit(parent, {x, y, ModelSetupPage::NAM_W, 0}, g_model.header.name, sizeof(g_model.header.name), [=]() { - auto model = modelslist.getCurrentModel(); + auto model = modelCellManager.getCurrentModel(); if (model) { model->setModelName(g_model.header.name); } @@ -403,29 +403,23 @@ const static SetupLineDef setupLines[] = { // Model labels STR_DEF(STR_LABELS), [](Window* parent, coord_t x, coord_t y) { - auto curmod = modelslist.getCurrentModel(); - TextButton* btn = new TextButton(parent, {x, y, 0, 0}, modelslabels.getBulletLabelString(curmod, STR_UNLABELEDMODEL)); + auto curmod = modelCellManager.getCurrentModel(); + TextButton* btn = new TextButton(parent, {x, y, 0, 0}, curmod->getBulletLabelString(STR_UNLABELEDMODEL)); btn->setPressHandler([=]() { Menu *menu = new Menu(true); menu->setTitle(STR_LABELS); - for (auto &label : modelslabels.getLabels()) { + for (auto &label : modelCellManager.getLabels()) { menu->addLineBuffered( label, [=]() { - if (!modelslabels.isLabelSelected(label, curmod)) - modelslabels.addLabelToModel(label, curmod); + if (!curmod->hasLabel(label)) + modelCellManager.addLabelToModel(label, curmod); else - modelslabels.removeLabelFromModel(label, curmod); - btn->setText(modelslabels.getBulletLabelString( - curmod, STR_UNLABELEDMODEL)); - strncpy(g_model.header.labels, - ModelMap::toCSV(modelslabels.getLabelsByModel(curmod)) - .c_str(), - sizeof(g_model.header.labels)); - g_model.header.labels[sizeof(g_model.header.labels) - 1] = '\0'; + modelCellManager.removeLabelFromModel(label, curmod); + btn->setText(curmod->getBulletLabelString(STR_UNLABELEDMODEL)); SET_DIRTY(); }, - [=]() { return modelslabels.isLabelSelected(label, curmod); }); + [=]() { return curmod->hasLabel(label); }); } menu->updateLines(); return 0; @@ -442,7 +436,7 @@ const static SetupLineDef setupLines[] = { }, [=](std::string newValue) { strncpy(g_model.header.bitmap, newValue.c_str(), LEN_BITMAP_NAME); - auto model = modelslist.getCurrentModel(); + auto model = modelCellManager.getCurrentModel(); if (model) { strncpy(model->modelBitmap, newValue.c_str(), LEN_BITMAP_NAME); model->modelBitmap[LEN_BITMAP_NAME] = '\0'; diff --git a/radio/src/gui/colorlcd/module/module_setup.cpp b/radio/src/gui/colorlcd/module/module_setup.cpp index 21e88b1da..d42fc2d70 100644 --- a/radio/src/gui/colorlcd/module/module_setup.cpp +++ b/radio/src/gui/colorlcd/module/module_setup.cpp @@ -233,7 +233,7 @@ class ModuleWindow : public Window GET_DEFAULT(*modelId), [=](int32_t newValue) { if (newValue != *modelId) { *modelId = newValue; - modelslist.updateCurrentModelCell(); + modelCellManager.updateCurrentModelCell(); updateIDStaticText(moduleIdx); #if defined(CROSSFIRE) if (isModuleCrossfire(moduleIdx)) { @@ -546,7 +546,7 @@ class ModuleWindow : public Window if (idUnique == nullptr) return; char buffer[50]; std::string idStr = STR_MODELIDUNIQUE; - if (!modelslist.isModelIdUnique(mdIdx, buffer, sizeof(buffer))) { + if (!modelCellManager.isModelIdUnique(mdIdx, buffer, sizeof(buffer))) { idStr = STR_MODELIDUSED; idStr = idStr + buffer; lv_obj_add_state(idUnique->getLvObj(), ETX_STATE_UNIQUE_ID_WARN); diff --git a/radio/src/gui/colorlcd/radio/radio_setup.cpp b/radio/src/gui/colorlcd/radio/radio_setup.cpp index 7788d5f91..157299d72 100644 --- a/radio/src/gui/colorlcd/radio/radio_setup.cpp +++ b/radio/src/gui/colorlcd/radio/radio_setup.cpp @@ -754,7 +754,7 @@ const static SetupLineDef manageModelsSetupLines[] = { GET_DEFAULT(g_eeGeneral.labelSingleSelect), [=](int newValue) { g_eeGeneral.labelSingleSelect = newValue; - modelslabels.clearFilter(); + modelCellManager.clearFilter(); Messaging::send(Messaging::REFRESH); SET_DIRTY(); }); diff --git a/radio/src/storage/modelslist.cpp b/radio/src/storage/modelslist.cpp index d5779aa9c..43687dd2a 100644 --- a/radio/src/storage/modelslist.cpp +++ b/radio/src/storage/modelslist.cpp @@ -21,27 +21,12 @@ #include "modelslist.h" -#include - -using std::list; - #include "edgetx.h" #include "storage/sdcard_yaml.h" #include "yaml/yaml_datastructs.h" #include "yaml/yaml_labelslist.h" #include "yaml/yaml_modelslist.h" #include "yaml/yaml_parser.h" -#include "os/sleep.h" - -#if defined(USBJ_EX) -#include "usb_joystick.h" -#endif - -#include - -#include "datastructs.h" -#include "myeeprom.h" -#include "pulses/modules_helpers.h" #include "strhelpers.h" #ifdef DEBUG_LABELS @@ -52,344 +37,32 @@ using std::list; LAYOUT_SIZE(LABEL_TRUNCATE_LENGTH, 21, 16) -ModelsList modelslist; -ModelMap modelslabels; - -ModelCell::ModelCell(const char *fileName) : valid_rfData(false) -{ - strncpy(modelFilename, fileName, sizeof(modelFilename) - 1); - modelFilename[sizeof(modelFilename) - 1] = '\0'; -} - -void ModelCell::setModelName(char *name) -{ - strncpy(modelName, name, LEN_MODEL_NAME); - modelName[LEN_MODEL_NAME] = '\0'; - - if (modelName[0] == '\0') { - char *tmp; - strncpy(modelName, modelFilename, LEN_MODEL_NAME); - tmp = (char *)memchr(modelName, '.', LEN_MODEL_NAME); - if (tmp != nullptr) *tmp = 0; - } -} - -void ModelCell::setRfData(ModelHeader *header, ModuleData* modData) -{ - for (uint8_t i = 0; i < NUM_MODULES; i++) { - modelId[i] = header->modelId[i]; - setRfModuleData(i, &(modData[i])); - TRACE("<%s/%i> : %X,%X,%X", strlen(modelName) ? modelName : modelFilename, - i, moduleData[i].type, moduleData[i].subType, modelId[i]); - } - valid_rfData = true; -} - -void ModelCell::setRfModuleData(uint8_t moduleIdx, ModuleData *modData) -{ - moduleData[moduleIdx].type = modData->type; - if (modData->type != MODULE_TYPE_MULTIMODULE) { - moduleData[moduleIdx].subType = (uint8_t)modData->subType; - } else { - // do we care here about MM_RF_CUSTOM_SELECTED? probably not... - moduleData[moduleIdx].subType = modData->multi.rfProtocol; - } -} +ModelsList modelCellManager; //----------------------------------------------------------------------------- -/** - * @brief Gets all models which don't have any labels selected - * - * @param sortby - * @return ModelsVector vector - */ -ModelsVector ModelMap::getUnlabeledModels() +// TODO - Fix me, ideally there should be no limitations +static void escapeCSV(std::string &str) { - ModelsVector unlabeledModels; - for (auto model : modelslist) { - if (modelslabels.getLabelsByModel(model).size() == 0) - unlabeledModels.emplace_back(model); + strReplaceAll(str, "/", "//"); + strReplaceAll(str, ",", "/c"); +} + +// TODO - Fix me, ideally there should be no limitations +static void unEscapeCSV(std::string &str) +{ + strReplaceAll(str, "//", "/"); + strReplaceAll(str, "/c", ","); +} + +// TODO - Fix me, ideally there should be no limitations +static void removeYAMLChars(std::string &str) +{ + std::string s; + for (int i = 0; labelExcludedChars[i]; i += 1) { + s = labelExcludedChars[i]; + strReplaceAll(str, s, ""); } - sortModelsBy(unlabeledModels, _sortOrder); - return unlabeledModels; -} - -/** - * @brief Returns a sorted list of all models - */ - -ModelsVector ModelMap::getAllModels() -{ - ModelsVector all = modelslist; - sortModelsBy(all, _sortOrder); - return all; -} - -/** - * @brief Returns all models that have a sepecified label - * - * @param lbl Label to search - * @return ModelsVector aka vector of all models belonging to a - * label - */ - -ModelsVector ModelMap::getModelsByLabel(const std::string &lbl) -{ - int index = getIndexByLabel(lbl); - if (index < 0) return ModelsVector(); - ModelsVector rv; - for (auto it = begin(); it != end(); ++it) { - if (it->first == index) rv.push_back(it->second); - } - sortModelsBy(rv, _sortOrder); - return rv; -} - -/** - * @brief Returns all models that match the selected labels - * - * @param lbls Labels to search - * @return ModelsVector aka vector of all models belonging to a - * label - */ - -ModelsVector ModelMap::getModelsInLabels(const LabelsVector &lbls) -{ - if (lbls.size() == 0) return ModelsVector(); - - // Requesting only Unlabeled models - if (lbls.size() == 1 && lbls.at(0) == STR_UNLABELEDMODEL) - return getUnlabeledModels(); - - ModelsVector rv; - - for (const auto &mdl : modelslist) { - bool hasAllLabels = true; - bool hasAnyLabels = false; - bool favLabelIncluded = false; - bool hasFavLabel = false; - LabelsVector mdllables = getLabelsByModel(mdl); - for (const auto &lbl : lbls) { - if (lbl == STR_UNLABELEDMODEL) // If requesting unlabeled model ignore it - break; - bool hasLabel = std::find(mdllables.begin(), mdllables.end(), lbl) != mdllables.end(); - if (lbl == STR_FAVORITE_LABEL) { - favLabelIncluded = true; - hasFavLabel = hasLabel; - } else { - if (hasLabel) { - hasAnyLabels = true; - } else { - hasAllLabels = false; - } - } - } - if (favLabelIncluded) { - if (g_eeGeneral.favMultiMode == 0) { - hasAnyLabels = hasAnyLabels && hasFavLabel; - hasAllLabels = hasAllLabels && hasFavLabel; - } else if (g_eeGeneral.favMultiMode == 1) { - hasAnyLabels = hasAnyLabels || hasFavLabel; - hasAllLabels = hasAllLabels && hasFavLabel; - } - } - if (((g_eeGeneral.labelMultiMode == 0) && hasAllLabels) || - ((g_eeGeneral.labelMultiMode == 1) && hasAnyLabels)) - rv.push_back(mdl); - } - - sortModelsBy(rv, _sortOrder); - return rv; -} - -/** - * @brief Gets all labels that a model currently has selected - * - * @param mdl Model to search - * @return LabelsVector aka vector of all labels selected by a model - */ - -LabelsVector ModelMap::getLabelsByModel(ModelCell *mdl) -{ - if (mdl == nullptr) return LabelsVector(); - LabelsVector rv; - for (auto it = begin(); it != end(); ++it) { - if (it->second == mdl) { - rv.push_back(getLabelByIndex(it->first)); - } - } - return rv; -} - -/** - * @brief Get a map of all labels and their selection status - * @details Returns a map of all the labels and if they are selected in a model. - * - * @param cell ModelCell to search - * @return std::map Map Containing all labels with a boolean - * if they are selected - */ - -std::map ModelMap::getSelectedLabels(ModelCell *cell) -{ - std::map rval; - for (auto lbl : labels) { // Add all labels - if (lbl == "") continue; - rval[lbl] = false; - } - LabelsVector modlbl = getLabelsByModel(cell); - for (const auto &ml : modlbl) { // Set to true if selected by the model - rval[ml] = true; - } - return rval; -} - -/** - * @brief Returns if a label is selected in the model - * - * @param label Label to search - * @param cell Model to search - * @return true Label is selected - * @return false Label not seleced OR label not found - */ - -bool ModelMap::isLabelSelected(const std::string &label, ModelCell *cell) -{ - auto lbm = getLabelsByModel(cell); - if (std::find(lbm.begin(), lbm.end(), label) == lbm.end()) { - return false; - } - return true; -} - -/** - * @brief Returns a list of the current labels - * - * @return LabelsVector vector of all labels - */ - -LabelsVector ModelMap::getLabels() -{ - LabelsVector rv; - for (auto &label : labels) { - // Skip the internal empty (removed) ones - if (label.size() > 0) rv.emplace_back(label); - } - return rv; -} - -/** - * @brief Adds a label - * @details Checks if the label already exists. If it does it returns the - * index to it. If label doesn't exist it adds it at the end of the - * list and returns the new index - * Won't allow creation of the special case label "Unlabeled" STR_UNLABELEDMODEL - * - * @param lbl Adds a label to the list - * @return int -1 on failure, label index on success - */ - -int ModelMap::addLabel(std::string lbl) -{ - if (lbl == STR_UNLABELEDMODEL) return -1; - - // Limit maximum label length, TODO... Truncate UTF8 Properly - removeYAMLChars(lbl); - lbl = lbl.substr(0, LABEL_LENGTH); - if (lbl.size() == 0) return -1; - - // Add a new label if it doesn't already exist in the list - // Returns the index to the label - int ind = getIndexByLabel(lbl); - if (ind < 0) { - labels.push_back(lbl); - setDirty(); - TRACE_LABELS("Added a label %s", lbl.c_str()); - return labels.size() - 1; - } - return ind; -} - -/** - * @brief Adds a label to a model - * - * @param lbl Label to be added - * @param cell Model to add the label - * @param update If true will update the model file - * @return true Couldn't add label, not enough memory available in labels string - * @return false Success - */ - -bool ModelMap::addLabelToModel(const std::string &lbl, ModelCell *cell, - bool update) -{ - // First check that there aren't too many labels on this model - LabelsVector lbs = getLabelsByModel(cell); - lbs.push_back(lbl); - if(ModelMap::toCSV(lbs).size() > LABELS_LENGTH - 1) { - TRACE("Cannot add the %s label to the model. Too many labels", lbl.c_str()); - return true; - } - - setDirty(); - int labelindex = addLabel(lbl); - insert(std::pair(labelindex, cell)); - - if (update) updateModelFile(cell); // Write labels into model - - return false; -} - -/** - * @brief Adds a label to the filter, used in yaml_labelslist on load - * - * @param label Label to be added - */ - -void ModelMap::addFilteredLabel(const std::string &lbl) -{ - int ind = getIndexByLabel(lbl); - if (ind >= 0) filtlbls.insert(ind); -} - -bool ModelMap::isLabelFiltered(const std::string &lbl) -{ - if (filtlbls.find(getIndexByLabel(lbl)) != filtlbls.end()) return true; - return false; -} - -void ModelMap::setSortOrder(ModelsSortBy sortby) -{ - if (sortby < SORT_COUNT && sortby >= NO_SORT) { - _sortOrder = sortby; - setDirty(); - } -} - -/** - * @brief Convert a vector of labels to a comma separated list - * - * @param labels Vector of the labels - * @return labels list in CSV format - */ - -std::string ModelMap::toCSV(const LabelsVector &labels) -{ - std::string csv; - bool comma=false; - for(auto lbl: labels) { - if(lbl == "") - continue; - if(comma) { - csv += ','; - } - // Escape out commas - escapeCSV(lbl); - csv += lbl; - comma = true; - } - return csv; } /** @@ -399,7 +72,7 @@ std::string ModelMap::toCSV(const LabelsVector &labels) * @return vector of all labels */ -LabelsVector ModelMap::fromCSV(const char* str) +static LabelsVector fromCSV(const char* str) { LabelsVector lbls; const char* prev_c = str; @@ -421,305 +94,342 @@ LabelsVector ModelMap::fromCSV(const char* str) return lbls; } -// TODO - Fix me, ideally there should be no limitations -void ModelMap::escapeCSV(std::string &str) -{ - strReplaceAll(str, "/", "//"); - strReplaceAll(str, ",", "/c"); -} +/** + * @brief Create a string from a vector of labels joined with a given seperator + * + * @param labels Vector of the labels + * @param seperator String to seperate labels in teh result + * @return labels list as a string + */ -// TODO - Fix me, ideally there should be no limitations -void ModelMap::unEscapeCSV(std::string &str) +static std::string joinLabels(const LabelsVector &labels, const char* seperator) { - strReplaceAll(str, "//", "/"); - strReplaceAll(str, "/c", ","); -} - -// TODO - Fix me, ideally there should be no limitations -void ModelMap::removeYAMLChars(std::string &str) -{ - strReplaceAll(str, "\\", ""); - strReplaceAll(str, "\"", ""); - strReplaceAll(str, ":", ""); - strReplaceAll(str, "\'", ""); - strReplaceAll(str, "-", ""); + // If creating a CSV escape each label character for saving to YAML + bool escape = (strcmp(seperator, ",") == 0); + std::string res; + for (auto lbl: labels) { + if (lbl.size() > 0) { + if (escape) + escapeCSV(lbl); + if (res.size() > 0) + res += seperator; + res += lbl; + } + } + return res; } /** - * @brief Removes a label from a model + * @brief Convert a vector of labels to a comma separated list * - * @param label Label to be removed - * @param cell Model to remove the label from - * @return true Couldn't find the label - * @return false Success + * @param labels Vector of the labels + * @return labels list in CSV format */ -bool ModelMap::removeLabelFromModel(const std::string &label, ModelCell *cell, - bool update) +static std::string toCSV(const LabelsVector &labels) { - int lblind = getIndexByLabel(label); - if (lblind < 0) return true; - bool rv = true; - // Erase items that match in the map - for (ModelMap::const_iterator itr = cbegin(); itr != cend();) { - itr = (itr->first == lblind && itr->second == cell) ? erase(itr) - : std::next(itr); - setDirty(); - rv = false; + return joinLabels(labels, ","); +} + +/** + * @brief Creates a Hash based on the file information + * + * @param buffer Buffer to store the output data + * @param finfo Files info handle + * @return char* Pointer to buffer supplied + */ + +static char *FILInfoToHexStr(char buffer[FILE_HASH_LENGTH + 1], FILINFO *finfo) +{ + char *str = buffer; + for (unsigned int i = 0; i < sizeof(FInfoH); i++) { + sprintf(str, "%02x", *((uint8_t *)finfo + i)); + str += 2; } + return buffer; +} - if (update) updateModelFile(cell); // Write labels into model +//----------------------------------------------------------------------------- +void LabelsMap::addLabel(uint8_t idx) +{ + // Add a label - maintain sort order + if (!hasLabel(idx)) { + for (auto it = labelMap.begin(); it != labelMap.end(); ++it) { + if (*it > idx) { + labelMap.insert(it, idx); + return; + } + } + labelMap.emplace_back(idx); + } +} + +void LabelsMap::removeLabel(uint8_t idx) +{ + // Remove a label from the model (if present) + if (hasLabel(idx)) + labelMap.erase(std::remove(labelMap.begin(), labelMap.end(), idx), labelMap.end()); +} + +bool LabelsMap::deleteLabel(uint8_t idx) +{ + // Remove a label that has been deleted from the labels list + // Update label indexes to account for deletion + bool changed = false; + // Remove label if present - return changed state + if (hasLabel(idx)) { + removeLabel(idx); + changed = true; + } + // Fix label indexes after a label was deleted + for (auto it = labelMap.begin(); it != labelMap.end(); ++it) { + if (*it > idx) + *it = *it - 1; + } + return changed; +} + +void LabelsMap:: moveLabelUp(uint8_t idx) +{ + // Swap position of label at idx with idx - 1 + // Label position was moved so index has changed + + // Can't move top label + if (idx == 0) return; + // See which labels are present + bool hasIdx1 = hasLabel(idx); + bool hasIdx2 = hasLabel(idx - 1); + + // Both set so nothing to do + if (hasIdx1 && hasIdx2) return; + // Neither set so nothing to do + if (!hasIdx1 && !hasIdx2) return; + + // Remove set label and add the other one + if (hasIdx1) { + removeLabel(idx); + addLabel(idx - 1); + } else { + removeLabel(idx - 1); + addLabel(idx); + } +} + +bool LabelsMap::hasLabel(uint8_t idx) +{ + return std::find(labelMap.begin(), labelMap.end(), idx) != labelMap.end(); +} + +/** + * @brief Create a list of all the label names + * + * @param mdl Model to search + * @return LabelsVector aka vector of all labels selected by a model + */ + +LabelsVector LabelsMap::getLabels() +{ + LabelsVector rv; + for (auto it = labelMap.begin(); it != labelMap.end(); ++it) { + std::string s = modelCellManager.getLabelByIndex(*it); + // Ignore duplicates + if (std::find(rv.begin(), rv.end(), s) == rv.end()) { + rv.push_back(s); + } + } return rv; } -/** - * @brief Removes a label - * @details Remove a label from the list, only if there are no models that have - * the label selected. The label is set blank, when writing this will be - * skipped. Labels are re-synced - * - * @param label Label to be removed - * @return true Label wasn't found or not empty - * @return false Success - */ +//----------------------------------------------------------------------------- -bool ModelMap::removeLabel( - const std::string &label, - std::function progress) +ModelCell::ModelCell(const char *fileName) { - bool renameFault=true; - renameLabel(label, "", std::move(progress)); - for (auto &lbl : labels) { - if (lbl == label && getModelsByLabel(lbl).size() == 0) { - lbl = ""; - setDirty(); - renameFault = false; - } - } - - // If no more labels, add a favorite - if (!renameFault && getLabels().size() == 0) { - addLabel(STR_FAVORITE_LABEL); - } - - // Save and rescan - modelslist.save(); - modelslist.clear(); - modelslist.load(); - - return renameFault; + strAppend(modelFilename, fileName, LEN_MODEL_FILENAME); } -/** - * @brief Moves a labels order - * - * @param current Index of the label to move - * @param toindex Index where to place the new label - * @return 1 Failure - * @return 0 Success - */ - -bool ModelMap::moveLabelTo(unsigned curind, unsigned newind) +void ModelCell::setModelName(const char *name) { - if (curind == newind || curind >= labels.size() || newind >= labels.size()) - return true; - - if (labels.at(curind) == "") return true; - - std::swap(labels[curind], labels[newind]); - - ModelMap newmap; - newmap.labels = labels; - - for (auto &mm : modelslabels) { - uint16_t ind = mm.first; - if (ind == curind) - ind = newind; - else if (ind == newind) - ind = curind; - newmap.insert(std::make_pair(ind, mm.second)); + if (name && name[0]) { + strAppend(modelName, name, LEN_MODEL_NAME); + } else { + setDefaultName(); } - - modelslabels = newmap; - - modelslist.save(labels); - setDirty(); - - return false; } -/** - * @brief Rename a label - * @details Opens all models which have a label that matches the string. - * Renames the label with string and saves file. - * If working on the current model it replaces the label string in - * g_model - * - * @param from Label to search - * @param to Replacement label - * @return true failure Label couldn't be found, YAML couldn't be read - * @return false success - */ - -bool ModelMap::renameLabel(const std::string &from, std::string to, - std::function progress) +void ModelCell::setRfData(ModelHeader *header, ModuleData* modData) { - if (from == "") return true; - DEBUG_TIMER_START(debugTimerYamlScan); - - if(to.size() > 0) { // Ignore check if deleting a label, size will be zero - to = to.substr(0, LABEL_LENGTH); // Limit max label name. TODO: Warn user they entered too long of a string - removeYAMLChars(to); // Remove special chars - if(to.size() == 0 || from == to) { // Abort rename if no chars left or same - if (progress != nullptr) progress("", 100); // Kill progress dialog - return true; - } - } - - ModelsVector mods = getModelsByLabel(from); // Find all models to be renamed - - // Scan all these models first, recombine their labels to a csv, - // make sure re-size is going to fit before starting. Otherwise new partial - // labels would be created on next scan. - for(const auto &model: mods) { - int curlen = toCSV(getLabelsByModel(model)).size(); - std::string csvto = to; - escapeCSV(csvto); - std::string csvfrom = from; - escapeCSV(csvfrom); - if(curlen + csvto.size() - csvfrom.size() > LABELS_LENGTH - 1) { - TRACE("Labels: Rename Error! Labels too long on %s", model->modelName); - if (progress != nullptr) progress("", 100); // Kill progress dialog - return true; - } - } - - // Force a write of any changes in memory - storageCheck(true); - - bool fault = false; - int i = 0; - for (const auto &modcell : mods) { - if (progress != nullptr) { - progress(modcell->modelFilename, (i++) * 100 / mods.size()); - } - - PartialModel partial; - memclear(&partial, sizeof(PartialModel)); - readModelYaml(modcell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); - - // Separate Curent CSV - LabelsVector lbls = ModelMap::fromCSV(partial.header.labels); - - // Replace from->to strings - for (auto &lbl : lbls) { - if (lbl == from) lbl = to; - } - - // Remove possible duplicates - std::sort(lbls.begin(), lbls.end()); - auto last = std::unique(lbls.begin(), lbls.end()); - lbls.erase(last, lbls.end()); - lbls.resize(std::distance(lbls.begin(), last)); - - // Write back - strAppend(partial.header.labels, ModelMap::toCSV(lbls).c_str(), LABELS_LENGTH - 1); - - if (modcell == modelslist.getCurrentModel()) { - // If working on the current model, copy new labels mark dirty - memcpy(g_model.header.labels, partial.header.labels, LABELS_LENGTH); - storageDirty(EE_MODEL); + for (uint8_t i = 0; i < NUM_MODULES; i++) { + modelId[i] = header->modelId[i]; + moduleData[i].type = modData[i].type; + if (modData[i].type != MODULE_TYPE_MULTIMODULE) { + moduleData[i].subType = (uint8_t)modData[i].subType; } else { - fault = !writeModelLabels(modcell, partial.header.labels); + // do we care here about MM_RF_CUSTOM_SELECTED? probably not... + moduleData[i].subType = modData[i].multi.rfProtocol; } -#if defined(SIMU) - sleep_ms(100); -#endif + TRACE("<%s/%i> : %X,%X,%X", strlen(modelName) ? modelName : modelFilename, + i, moduleData[i].type, moduleData[i].subType, modelId[i]); } + valid_rfData = true; +} - // Rename the label - for (auto &lbl : labels) { - if (lbl == from) { - lbl = to; - setDirty(true); +void ModelCell::setUniqueName() +{ + // Generate a new unique name from the current name + char s[LEN_MODEL_NAME + 1]; + strAppend(s, modelName, LEN_MODEL_NAME); + int endPos = strlen(s); + if (endPos > LEN_MODEL_NAME - 3) + endPos = LEN_MODEL_NAME - 3; + for (int i = 1; i < 99; i += 1) { + if (i == 10 && (endPos > LEN_MODEL_NAME - 4)) + endPos = LEN_MODEL_NAME - 4; + sprintf(s + endPos, "(%d)", i); + if (modelCellManager.getModelWithName(s) == nullptr) { + strAppend(modelName, s, LEN_MODEL_NAME); + return; } } - // Make sure to leave at 100, to kill rename dialog - if (progress != nullptr) progress("", 100); + // Fallback + setDefaultName(); +} - // Issue a rescan all of all models. - modelslist.clear(); - modelslist.load(); +void ModelCell::setDefaultName() +{ + // Reset name back to default 'MODELxx' where 'xx' is the model number + // from the file name + int idx = -1; + sscanf(modelFilename, "model%d", &idx); + if (idx > 0) + sprintf(modelName, "MODEL%02d" MODEL_FILENAME_SUFFIX, idx); + else + strAppend(modelName, modelFilename, LEN_MODEL_NAME); + char* tmp = (char *)strrchr(modelName, '.'); + if (tmp != nullptr) *tmp = 0; +} -#if defined(DEBUG_TIMERS) - DEBUG_TIMER_SAMPLE(debugTimerYamlScan); - TRACE("Labels: Time to rename %d labels %luus", mods.size(), - debugTimers[debugTimerYamlScan].getLast()); +void ModelCell::duplicateFrom(ModelCell* src) +{ + // Copy everything except the filename and hash from the src model + strAppend(modelName, src->modelName, LEN_MODEL_NAME); +#if LEN_BITMAP_NAME > 0 + strAppend(modelBitmap, src->modelBitmap, LEN_BITMAP_NAME); #endif + lastOpened = src->lastOpened; + _isDirty = src->_isDirty; + valid_rfData = src->valid_rfData; + for (int i = 0; i < NUM_MODULES; i += 1) { + moduleData[i].type = src->moduleData[i].type; + moduleData[i].subType = src->moduleData[i].subType; + } + labelsMap = src->labelsMap; +} - return fault; +void ModelCell::updateModelFile() +{ + // Update labels and write model file + std::string csv = toCSV(getLabels()); + if (this == modelCellManager.getCurrentModel()) { + strAppend(g_model.header.labels, csv.c_str(), LABELS_LENGTH); + storageDirty(EE_MODEL); + } else { + if (writeModelLabels(csv.c_str())) { + updateFinfoHash(); + } else{ + TRACE("ERROR updating model file '%s'", modelFilename); + } + } } /** - * @brief Returns a bullet separated list of the labels, used in model_setup - * - * @param curmod Model Cell - * @param noresults String to return when no labels found - * @return std::string of all Labels, if no results return + * @brief Opens a YAML file, reads the data and updates the ModelCell */ -std::string ModelMap::getBulletLabelString(ModelCell *curmod, const char *noresults) +void ModelCell::updateModelCell() { - std::string lbls = ModelMap::toCSV(getLabelsByModel(curmod)); - strReplaceAll(lbls, ",", "\u2022"); - unEscapeCSV(lbls); + TRACE("Labels: Updating model %s", modelFilename); - if(lbls.size() == 0) { - return noresults; - } - if(lbls.size() > LABEL_TRUNCATE_LENGTH) { - lbls = lbls.substr(0, LABEL_TRUNCATE_LENGTH); - lbls += "..."; - } - return lbls; + PartialModel partial; + memclear(&partial, sizeof(PartialModel)); + readModelYaml(modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); + + strAppend(modelName, partial.header.name, LEN_MODEL_NAME); + strAppend(modelBitmap, partial.header.bitmap, LEN_BITMAP_NAME); + + modelCellManager.addLabelsToModel(partial.header.labels, this); + + // Save Module Data + setRfData(&partial.header, partial.moduleData); + + _isDirty = false; } -/** - * @brief Removes all models from the map - * - * @param cell Model to remove - * @return true Failure finding a model - * @return false Success - */ -bool ModelMap::removeModels(ModelCell *cell) +/* + TODO: Revisit once labels.yml records the version that wrote it and + cached model data is discarded on a version mismatch. It *will* add + extra overhead, writes to labels.yml and hasn't been fully tested. + + Currently the file hashes are only synchronised between the labels.yml file + and the model files on startup. Any updates to model files while running + do not update the labels.yml copy of the file hash. + + This function can be used to re-sync a model file to the labels file hash +*/ +void ModelCell::updateFinfoHash() { - bool rv = true; - // Erase items that match in the map - for (ModelMap::const_iterator itr = cbegin(); itr != cend();) { - if (itr->second == cell) { - itr = erase(itr); - setDirty(); - rv = false; - } else { - itr = std::next(itr); - } +#if 0 + // Recalculate file info hash and save to SD + FILINFO finfo; + char path[256]; + getModelPath(path, modelFilename); + FRESULT result = f_stat(path, &finfo); + if (result == FR_OK) { + FILInfoToHexStr(modelFinfoHash, &finfo); + storageDirty(EE_LABELS); } - return rv; +#endif +} + +void ModelCell::addLabel(uint8_t idx) +{ + labelsMap.addLabel(idx); +} + +void ModelCell::removeLabel(uint8_t idx) +{ + labelsMap.removeLabel(idx); +} + +void ModelCell::deleteLabel(uint8_t idx) +{ + if (labelsMap.deleteLabel(idx)) + updateModelFile(); +} + +void ModelCell::moveLabelUp(uint8_t idx) +{ + labelsMap.moveLabelUp(idx); +} + +bool ModelCell::hasLabel(const std::string& label) +{ + int idx = modelCellManager.getIndexByLabel(label); + return labelsMap.hasLabel(idx); } /** * @brief Update the labels in an existing model yaml file on SD card * - * @param cell Model to update + * @param labels new labels * @return true Success * @return false Failure */ -bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) +bool ModelCell::writeModelLabels(const char* labels) { - TRACE("Updating labels in %s",cell->modelFilename); + TRACE("Updating labels in %s",modelFilename); UINT bytes_cnt; char buf[512]; @@ -730,10 +440,13 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) // Read exiting model header PartialModel partial; memclear(&partial, sizeof(PartialModel)); - readModelYaml(cell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); + readModelYaml(modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); + // Update name (may have been changed when duplicating model) + // strAppend is not safe as it assumes dest buffer has room for nul terminator + strncpy(partial.header.name, modelName, LEN_MODEL_NAME); // Update header with new labels - strAppend(partial.header.labels, labels, LABELS_LENGTH - 1); + strAppend(partial.header.labels, labels, LABELS_LENGTH); // Remove module data - only want to write the header memclear(&partial.moduleData, sizeof(ModuleData) * NUM_MODULES); @@ -754,7 +467,7 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) } // Copy rest of model yaml from original file to temp file - getModelPath(buf, cell->modelFilename); + getModelPath(buf, modelFilename); result = f_open(&file, buf, FA_OPEN_EXISTING | FA_READ); if (result != FR_OK) { f_close(&out); @@ -782,7 +495,7 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) n += 1; if (n >= len - 7) { - TRACE("ERROR model header not found in %s", cell->modelFilename); + TRACE("ERROR model header not found in %s", modelFilename); f_close(&out); f_close(&file); f_unlink(tempPath); @@ -797,7 +510,7 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) } while ((n < len) && buf[n] == ' '); if (n >= len) { - TRACE("ERROR could not match model header in %s", cell->modelFilename); + TRACE("ERROR could not match model header in %s", modelFilename); f_close(&out); f_close(&file); f_unlink(tempPath); @@ -830,10 +543,10 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) } // Delete original file and rename temp file - getModelPath(buf, cell->modelFilename); + getModelPath(buf, modelFilename); f_unlink(buf); if (f_rename(tempPath, buf) != FR_OK) { - TRACE("ERROR renaming temp file to %s", cell->modelFilename); + TRACE("ERROR renaming temp file to %s", modelFilename); return false; } @@ -841,144 +554,44 @@ bool ModelMap::writeModelLabels(ModelCell* cell, const char* labels) } /** - * @brief Opens a model.yml File and writes labels data into it. - * @details If the cell is current model then write the labels data to g_model - * and mark as dirty + * @brief Returns a bullet separated list of the labels, used in model setup * - * @param cell - * @return true - * @return false + * @param curmod Model Cell + * @param noresults String to return when no labels found + * @return std::string of all Labels, if no results return */ -bool ModelMap::updateModelFile(ModelCell *cell) +std::string ModelCell::getBulletLabelString(const char *noresults) { - // Update memory copy if on current model - if (cell == modelslist.getCurrentModel()) { - strncpy(g_model.header.labels, ModelMap::toCSV(getLabelsByModel(cell)).c_str(), - LABELS_LENGTH - 1); - g_model.header.labels[LABELS_LENGTH - 1] = '\0'; - storageDirty(EE_MODEL); - return false; + std::string lbls = joinLabels(getLabels(), "\u2022"); + + if(lbls.size() == 0) + return noresults; + + if(lbls.size() > LABEL_TRUNCATE_LENGTH) { + lbls = lbls.substr(0, LABEL_TRUNCATE_LENGTH); + lbls += "..."; } - bool fault = !writeModelLabels(cell, ModelMap::toCSV(getLabelsByModel(cell)).c_str()); - -#if defined(DEBUG_TIMERS) - DEBUG_TIMER_SAMPLE(debugTimerYamlScan); - TRACE("Labels: Time to add/remove labels %luus", - debugTimers[debugTimerYamlScan].getLast()); -#endif - - return fault; -} - -/** - * @brief Sorts a ModelsVector by sortby - * - * @param mv ModelsVector to sort - * @param sortby NO_SORT, NAME_ASC, NAME_DES, DATE_ASC, DATE_DES, - */ - -void ModelMap::sortModelsBy(ModelsVector &mv, ModelsSortBy sortby) -{ - if (sortby == DATE_DES) { - std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { - return a->lastOpened > b->lastOpened; - }); - } else if (sortby == DATE_ASC) { - std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { - return a->lastOpened < b->lastOpened; - }); - } else if (sortby == NAME_ASC) { - std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { - return strcasecmp(a->modelName, b->modelName) < 0; - }); - } else if (sortby == NAME_DES) { - std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { - return strcasecmp(a->modelName, b->modelName) > 0; - }); - } -} - -/** - * @brief Sets the ModelMap to dirty. - * @details Causes labels.yml to be written after a delay in - * sdcard_common.cpp->storageCheck() - */ - -void ModelMap::setDirty(bool save) -{ - _isDirty = true; - storageDirty(EE_LABELS); - if (save) storageCheck(true); + return lbls; } //----------------------------------------------------------------------------- -ModelsList::ModelsList() { init(); } +ModelsList::ModelsList() {} ModelsList::~ModelsList() { clear(); } -void ModelsList::init() -{ - loaded = false; - currentModel = nullptr; -} - void ModelsList::clear() { + labels.clear(); + for(ModelCell *mdl: *this) { delete(mdl); } std::vector::clear(); - init(); -} -/** - * @brief Opens a YAML file, reads the data and updates the ModelCell - * - * @param cell Model to update - */ - -void ModelMap::updateModelCell(ModelCell *cell) -{ - modelslabels.removeModels(cell); - - TRACE("Labels: Updating model %s", cell->modelFilename); - - PartialModel partial; - memclear(&partial, sizeof(PartialModel)); - readModelYaml(cell->modelFilename, (uint8_t*)&partial, sizeof(PartialModel)); - - strAppend(cell->modelName, partial.header.name, LEN_MODEL_NAME); - strAppend(cell->modelBitmap, partial.header.bitmap, LEN_BITMAP_NAME); - LabelsVector labels = ModelMap::fromCSV(partial.header.labels); - for(const auto &lbl : labels ) { - modelslabels.addLabelToModel(lbl,cell); - } - - // Save Module Data - cell->setRfData(&partial.header, partial.moduleData); - - cell->_isDirty = false; -} - -/** - * @brief Creates a Hash based on the file information - * - * @param buffer Buffer to store the output data - * @param finfo Files info handle - * @return char* Pointer to buffer supplied - */ - -char *FILInfoToHexStr(char buffer[17], FILINFO *finfo) -{ - char *str = buffer; - for (unsigned int i = 0; i < sizeof(FInfoH); i++) { - sprintf(str, "%02x", *((uint8_t *)finfo + i)); - str += 2; - } - return buffer; + currentModel = nullptr; } /** @@ -991,19 +604,20 @@ char *FILInfoToHexStr(char buffer[17], FILINFO *finfo) bool ModelsList::loadYaml() { // Clear labels + map - modelslist.clear(); - modelslabels.clear(); - fileHashInfo.clear(); + clear(); DEBUG_TIMER_START(debugTimerYamlScan); + FRESULT result; + FIL file; + // Scan all models in folder DIR moddir; FILINFO finfo; if (f_opendir(&moddir, MODELS_PATH) == FR_OK) { for (;;) { - FRESULT res = f_readdir(&moddir, &finfo); - if (res != FR_OK || finfo.fname[0] == 0) break; + result = f_readdir(&moddir, &finfo); + if (result != FR_OK || finfo.fname[0] == 0) break; if (finfo.fattrib & AM_DIR) continue; unsigned int len = strlen(finfo.fname); @@ -1027,24 +641,13 @@ bool ModelsList::loadYaml() } // Store hash & filename - filedat cf; - FILInfoToHexStr(cf.hash, &finfo); - cf.name = finfo.fname; - cf.celladded = false; - if (!strncmp(finfo.fname, g_eeGeneral.currModelFilename, - LEN_MODEL_FILENAME)) - cf.curmodel = true; - else - cf.curmodel = false; - fileHashInfo.push_back(cf); - TRACE_LABELS("File - %s \r\n HASH - %s - CM: %s", finfo.fname, cf.hash, - cf.curmodel ? "Y" : "N"); + auto model = addModel(finfo.fname, false); + FILInfoToHexStr(model->modelFinfoHash, &finfo); + TRACE_LABELS("File - %s \r\n HASH - %s", finfo.fname, model->modelFinfoHash); } f_closedir(&moddir); } - FRESULT result; - #if defined(DEBUG_TIMERS) DEBUG_TIMER_SAMPLE(debugTimerYamlScan); TRACE("Labels: Time to scan models folder %luus", @@ -1073,46 +676,30 @@ bool ModelsList::loadYaml() debugTimers[debugTimerYamlScan].getLast()); #endif - // Add modelcells for any remaining models that weren't in labels.yml - for (auto &filehash : fileHashInfo) { - ModelCell *model = NULL; - if (filehash.celladded == false) { - TRACE_LABELS(" Created a modelcell for %s, not in labels.yml", - filehash.name.c_str()); - model = new ModelCell(filehash.name.c_str()); - strncpy(model->modelFinfoHash, filehash.hash, FILE_HASH_LENGTH); - model->modelFinfoHash[FILE_HASH_LENGTH] = '\0'; - modelslist.push_back(model); - filehash.celladded = true; - model->_isDirty = true; - if(filehash.curmodel == true) - modelslist.setCurrentModel(model); - } - } + // Activate current model from radio settings + auto mdl = getModel(g_eeGeneral.currModelFilename); + if (mdl) + setCurrentModel(mdl); // Scan all models, to see if update needed bool updatelabelsyml = false; - for (auto &model : modelslist) { + for (auto &model : *this) { // Open and read each model if it's marked as dirty if (model->_isDirty) { updatelabelsyml = true; - modelslabels.updateModelCell(model); + model->updateModelCell(); } } - fileHashInfo.clear(); + // If no labels found. Add a favorites label + if (getLabels().size() == 0) { + addLabel(STR_FAVORITE_LABEL); + } // If any items differed save the file if (updatelabelsyml == true) { TRACE_LABELS("LABELS.YML Wasn't in sync. Needs to be saved"); - modelslist.save(); - } else { - TRACE_LABELS("LABELS.YML Is in Sync! No models were read"); - } - - // If no labels found. Add a favorites label - if (modelslabels.getLabels().size() == 0) { - modelslabels.addLabel(STR_FAVORITE_LABEL); + storageDirty(EE_LABELS); } return true; @@ -1127,28 +714,23 @@ bool ModelsList::loadYaml() bool ModelsList::load() { - if (loaded) return true; - bool res = loadYaml(); if (!currentModel) { TRACE("ERROR no Current Model Found"); - if (modelslist.size()) { - modelslist.setCurrentModel(modelslist.at(0)); - strncpy(g_eeGeneral.currModelFilename, modelslist.at(0)->modelFilename, - sizeof(g_eeGeneral.currModelFilename)); - g_eeGeneral.currModelFilename[sizeof(g_eeGeneral.currModelFilename) - 1] = '\0'; + if (size()) { + setCurrentModel(at(0)); + strAppend(g_eeGeneral.currModelFilename, at(0)->modelFilename, LEN_MODEL_FILENAME); TRACE(" - Set current model to first available"); } else { TRACE(" - No Models Found, making a new one"); // No models found, make a new one - auto model = modelslist.addModel(createModel(), true); - modelslist.setCurrentModel(model); + auto model = addModel(createModel(), true); + setCurrentModel(model); updateCurrentModelCell(); } } - loaded = true; return res; } @@ -1160,8 +742,10 @@ bool ModelsList::load() * @return const char* Error String on failure */ -const char *ModelsList::save(LabelsVector newOrder) +const char *ModelsList::save() { + FIL file; + FRESULT result = f_open(&file, LABELSLIST_YAML_PATH, FA_CREATE_ALWAYS | FA_WRITE); if (result != FR_OK) return "Couldn't open labels.yml for writing"; @@ -1169,20 +753,18 @@ const char *ModelsList::save(LabelsVector newOrder) // Save current selection f_puts("Labels:\r\n", &file); - std::string cursel = modelslabels.getCurrentLabel(); - if(newOrder.empty()) - newOrder = modelslabels.getLabels(); + LabelsVector newOrder = modelCellManager.getLabels(); for (auto &lbl : newOrder) { f_printf(&file, " \"%s\":\r\n", lbl.c_str()); - if (modelslabels.isLabelFiltered(lbl)) + if (isLabelFiltered(lbl)) f_printf(&file, " selected: true\r\n", lbl.c_str()); } // Save current sort order - f_printf( &file, "Sort: %d\r\n", modelslabels.sortOrder()); + f_printf( &file, "Sort: %d\r\n", modelCellManager.sortOrder()); f_puts("Models:\r\n", &file); - for (auto &model : modelslist) { + for (auto &model : *this) { f_puts(" ", &file); f_puts(model->modelFilename, &file); f_puts(":\r\n", &file); @@ -1207,7 +789,7 @@ const char *ModelsList::save(LabelsVector newOrder) (unsigned int)model->moduleData[i].subType); } - f_printf(&file, " labels: \"%s\"\r\n", ModelMap::toCSV(modelslabels.getLabelsByModel(model)).c_str()); + f_printf(&file, " labels: \"%s\"\r\n", toCSV(model->getLabels()).c_str()); #if LEN_BITMAP_NAME > 0 f_puts(" bitmap: \"", &file); @@ -1221,7 +803,6 @@ const char *ModelsList::save(LabelsVector newOrder) f_puts("\r\n", &file); f_close(&file); - modelslabels.resetDirty(); return NULL; } @@ -1238,7 +819,7 @@ void ModelsList::setCurrentModel(ModelCell *cell) struct gtm t; gettime(&t); cell->lastOpened = gmktime(&t); - modelslabels.setDirty(); + storageDirty(EE_LABELS); #if defined(USBJ_EX) && defined(STM32) && !defined(SIMU) onUSBJoystickModelChanged(); @@ -1259,7 +840,7 @@ void ModelsList::updateCurrentModelCell() strAppend(currentModel->modelFilename, g_eeGeneral.currModelFilename, LEN_MODEL_FILENAME); currentModel->setModelName(g_model.header.name); currentModel->setRfData(&g_model.header, g_model.moduleData); - modelslabels.setDirty(); + storageDirty(EE_LABELS); } else { TRACE("ModelList Error - No Current Model"); } @@ -1277,19 +858,17 @@ void ModelsList::updateCurrentModelCell() ModelCell *ModelsList::addModel(const char *fileName, bool save, ModelCell *copyCell) { ModelCell *result = new ModelCell(fileName); - if (copyCell != nullptr) { // Duplicate all data - memcpy(result, copyCell, sizeof(ModelCell)); + if (copyCell != nullptr) { + // Duplicate all data except filename and hash + result->duplicateFrom(copyCell); } - // Set the new File Name - strncpy(result->modelFilename, fileName, LEN_MODEL_FILENAME); - result->modelFilename[LEN_MODEL_FILENAME] = '\0'; - // Add to the ModelsList push_back(result); // Force save to labels.yml - if (save) this->save(); + if (save) storageDirty(EE_LABELS); + return result; } @@ -1307,7 +886,6 @@ ModelCell *ModelsList::addModel(const char *fileName, bool save, ModelCell *copy bool ModelsList::removeModel(ModelCell *model) { erase(std::remove(begin(), end(), model), end()); - modelslabels.removeModels(model); // Create deleted folder if it doesn't exist DIR deletedFolder; @@ -1332,6 +910,8 @@ bool ModelsList::removeModel(ModelCell *model) // Free memory delete(model); + storageDirty(EE_LABELS); + return false; } @@ -1348,15 +928,14 @@ bool ModelsList::removeModel(ModelCell *model) bool ModelsList::isModelIdUnique(uint8_t moduleIdx, char *warn_buf, size_t warn_buf_len) { - ModelCell *modelCell = modelslist.getCurrentModel(); - if (!modelCell || !modelCell->valid_rfData) { + if (!currentModel || !currentModel->valid_rfData) { // in doubt, pretend it's unique return true; } - uint8_t modelId = modelCell->modelId[moduleIdx]; - uint8_t type = modelCell->moduleData[moduleIdx].type; - uint8_t subType = modelCell->moduleData[moduleIdx].subType; + uint8_t modelId = currentModel->modelId[moduleIdx]; + uint8_t type = currentModel->moduleData[moduleIdx].type; + uint8_t subType = currentModel->moduleData[moduleIdx].subType; uint8_t additionalOnes = 0; char *curr = warn_buf; @@ -1365,7 +944,7 @@ bool ModelsList::isModelIdUnique(uint8_t moduleIdx, char *warn_buf, bool hit_found = false; for (auto it = begin(); it != end(); ++it) { - if (modelCell == *it) continue; + if (currentModel == *it) continue; if (!(*it)->valid_rfData) continue; @@ -1413,19 +992,18 @@ bool ModelsList::isModelIdUnique(uint8_t moduleIdx, char *warn_buf, uint8_t ModelsList::findNextUnusedModelId(uint8_t moduleIdx) { - ModelCell *modelCell = modelslist.getCurrentModel(); - if (!modelCell || !modelCell->valid_rfData) { + if (!currentModel || !currentModel->valid_rfData) { return 0; } - uint8_t type = modelCell->moduleData[moduleIdx].type; - uint8_t subType = modelCell->moduleData[moduleIdx].subType; + uint8_t type = currentModel->moduleData[moduleIdx].type; + uint8_t subType = currentModel->moduleData[moduleIdx].subType; uint8_t usedModelIds[(MAX_RXNUM + 7) / 8]; memset(usedModelIds, 0, sizeof(usedModelIds)); for (auto it = begin(); it != end(); it++) { - if (modelCell == *it) continue; + if (currentModel == *it) continue; if (!(*it)->valid_rfData) continue; @@ -1450,3 +1028,418 @@ uint8_t ModelsList::findNextUnusedModelId(uint8_t moduleIdx) // failed finding something... return 0; } + +void ModelsList::setSortOrder(ModelsSortBy sortby) +{ + if (sortby < SORT_COUNT && sortby >= NO_SORT) { + _sortOrder = sortby; + storageDirty(EE_LABELS); + } +} + +/** + * @brief Sorts a ModelsVector + * + * @param mv ModelsVector to sort + * @param sortby NO_SORT, NAME_ASC, NAME_DES, DATE_ASC, DATE_DES, + */ + +void ModelsList::sortModelsBy(ModelsVector &mv, ModelsSortBy sortby) +{ + if (sortby == DATE_DES) { + std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { + return a->lastOpened > b->lastOpened; + }); + } else if (sortby == DATE_ASC) { + std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { + return a->lastOpened < b->lastOpened; + }); + } else if (sortby == NAME_ASC) { + std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { + return strcasecmp(a->modelName, b->modelName) < 0; + }); + } else if (sortby == NAME_DES) { + std::sort(mv.begin(), mv.end(), [](ModelCell *a, ModelCell *b) -> bool { + return strcasecmp(a->modelName, b->modelName) > 0; + }); + } +} + +/** + * @brief Finds a ModelCell by filename + * + * @param filename Model file name + * @return ModelCell pointer if found, else null + */ + +ModelCell* ModelsList::getModel(const char* filename) +{ + for (auto model : *this) + if (strncmp(model->modelFilename, filename, LEN_MODEL_FILENAME) == 0) + return model; + return nullptr; +} + +/** + * @brief Finds a ModelCell by model name + * + * @param filename Model file name + * @return ModelCell pointer if found, else null + */ + +ModelCell* ModelsList::getModelWithName(const char* name) +{ + for (auto model : *this) + if (strncmp(model->modelName, name, LEN_MODEL_NAME) == 0) + return model; + return nullptr; +} + +/** + * @brief Gets all models which don't have any labels selected + * + * @param sortby + * @return ModelsVector vector + */ + +ModelsVector ModelsList::getUnlabeledModels() +{ + ModelsVector unlabeledModels; + for (auto model : *this) { + if (model->getLabels().size() == 0) + unlabeledModels.emplace_back(model); + } + sortModelsBy(unlabeledModels, sortOrder()); + return unlabeledModels; +} + +/** + * @brief Returns a sorted list of all models + */ + +ModelsVector ModelsList::getAllModels() +{ + ModelsVector all = *this; + sortModelsBy(all, sortOrder()); + return all; +} + +/** + * @brief Return the index for a label string + */ + +int ModelsList::getIndexByLabel(const std::string &str) +{ + auto a = std::find(labels.begin(), labels.end(), str); + return a == labels.end() ? -1 : a - labels.begin(); +} + +/** + * @brief Return the string for a label index + */ + +std::string ModelsList::getLabelByIndex(uint16_t index) +{ + if (index < (uint16_t)labels.size()) + return labels.at(index); + else + return std::string(); +} + +/** + * @brief Adds a label + * @details Checks if the label already exists. If it does it returns the + * index to it. If label doesn't exist it adds it at the end of the + * list and returns the new index + * Won't allow creation of the special case label "Unlabeled" STR_UNLABELEDMODEL + * + * @param lbl Adds a label to the list + * @return int -1 on failure, label index on success + */ + +int ModelsList::addLabel(std::string lbl) +{ + if (lbl == STR_UNLABELEDMODEL) return -1; + + // Limit maximum label length, TODO... Truncate UTF8 Properly + removeYAMLChars(lbl); + lbl = lbl.substr(0, LABEL_LENGTH); + if (lbl.size() == 0) return -1; + + // Add a new label if it doesn't already exist in the list + // Returns the index to the label + int ind = getIndexByLabel(lbl); + if (ind < 0) { + labels.push_back(lbl); + storageDirty(EE_LABELS); + TRACE_LABELS("Added a label %s", lbl.c_str()); + return labels.size() - 1; + } + return ind; +} + +/** + * @brief Removes a label + * @details Remove a label from the list. + * The label is removed from any models that are using it. + * + * @param label Label to be removed + * @param progress function to update progress bar (if needed) + */ + +void ModelsList::removeLabel( + const std::string &label, + std::function progress) +{ + int idx = getIndexByLabel(label); + if (idx >= 0) { + labels.erase(std::remove(labels.begin(), labels.end(), label), labels.end()); + + int i = 1; + for (auto it = begin(); it != end(); ++it, i += 1) { + if (progress != nullptr) progress((*it)->modelFilename, (i * 100) / size()); + (*it)->deleteLabel(idx); + } + + if (progress != nullptr) progress("", 100); // Kill progress dialog + + // If no more labels, add a favorite + if (labels.size() == 0) + addLabel(STR_FAVORITE_LABEL); + + storageDirty(EE_LABELS); + } +} + +/** + * @brief Moves a labels order up one place + * + * @param idx Index of the label to move + */ + +void ModelsList::moveLabelUp(uint16_t idx) +{ + if (idx == 0 || idx >= labels.size()) + return; + + std::swap(labels[idx - 1], labels[idx]); + + for (auto it = begin(); it != end(); ++it) { + (*it)->moveLabelUp(idx); + } + + storageDirty(EE_LABELS); +} + +/** + * @brief Update a label string + * + * @param from label to rename + * @param to new label string + * @param progress function to update progress bar (if needed) + */ + +void ModelsList::renameLabel(const std::string &from, const std::string& _to, + std::function progress) +{ + // Sanitize input - should not be needed as the text entry excludes + // illegal characters and limits length + std::string to = _to; + removeYAMLChars(to); + if (to.size() > LABEL_LENGTH) to.resize(LABEL_LENGTH); + + if (to.size() > 0 && from != to) { + int idx = getIndexByLabel(from); + if (idx >= 0) { + // Renaming to an existing label merges the two + int toIdx = getIndexByLabel(to); + + // Check that new label fits in all models using it + for (auto it = begin(); it != end(); ++it) { + if (!(*it)->hasLabel(idx)) continue; + LabelsVector lbls = (*it)->getLabels(); + if (toIdx >= 0 && (*it)->hasLabel(toIdx)) + lbls.erase(std::remove(lbls.begin(), lbls.end(), from), lbls.end()); // already has 'to' + else + std::replace(lbls.begin(), lbls.end(), from, to); + if (toCSV(lbls).size() > LABELS_LENGTH) { + TRACE("Labels: Rename Error! Labels too long on %s", (*it)->modelName); + if (progress != nullptr) progress("", 100); // Kill progress dialog + return; + } + } + + if (toIdx < 0) labels[idx] = to; + + // Update models + int i = 1; + for (auto it = begin(); it != end(); ++it, i += 1) { + if (progress != nullptr) progress((*it)->modelFilename, (i * 100) / size()); + if ((*it)->hasLabel(idx)) { + if (toIdx >= 0) { + (*it)->removeLabel(idx); + (*it)->addLabel(toIdx); + } + (*it)->updateModelFile(); + } + } + + if (toIdx >= 0) { + // Remove the old label, now unused; progress already reported 100% above + removeLabel(from, nullptr); + } + + storageDirty(EE_LABELS); + } + } + + if (progress != nullptr) progress("", 100); // Kill progress dialog +} + +/** + * @brief Adds a label to a model + * + * @param lbl Label to be added + * @param cell Model to add the label + * @param update If true will update the model file + * @return true Couldn't add label, not enough memory available in labels string + * @return false Success + */ + +bool ModelsList::addLabelToModel(const std::string &lbl, ModelCell *cell, bool update) +{ + // First check that there aren't too many labels on this model + LabelsVector lbs = cell->getLabels(); + lbs.push_back(lbl); + if(toCSV(lbs).size() > LABELS_LENGTH) { + TRACE("Cannot add the %s label to the model. Too many labels", lbl.c_str()); + return true; + } + + int labelindex = addLabel(lbl); + if (labelindex >= 0) { + cell->addLabel(labelindex); + if (update) cell->updateModelFile(); + storageDirty(EE_LABELS); + } + + return false; +} + +/** + * @brief Adds a set of labels (in a CSV string) to a model + * + * @param lbls Labels to be added + * @param cell Model to add the labels + */ + +void ModelsList::addLabelsToModel(const char* lbls, ModelCell* cell) +{ + LabelsVector vec = fromCSV(lbls); + for(const auto &lbl : vec) { + TRACE_LABELS(" Adding the label - %s", lbl.c_str()); + if (addLabelToModel(lbl, cell, false)) return; + } +} + +/** + * @brief Removes a label from a model + * + * @param label Label to be removed + * @param cell Model to remove the label from + */ + +void ModelsList::removeLabelFromModel(const std::string &label, ModelCell *cell) +{ + int idx = modelCellManager.getIndexByLabel(label); + if (idx >= 0) { + cell->removeLabel(idx); + cell->updateModelFile(); // Write labels into model + storageDirty(EE_LABELS); + } +} + +/** + * @brief Adds a label to the filter, used in yaml_labelslist on load + * + * @param label Label to be added + */ + +void ModelsList::addFilteredLabel(const std::string &lbl) +{ + int ind = getIndexByLabel(lbl); + if (ind >= 0) filtlbls.insert(ind); +} + +bool ModelsList::isLabelFiltered(const std::string &lbl) +{ + return (filtlbls.find(getIndexByLabel(lbl)) != filtlbls.end()); +} + +/** + * @brief Update label filter selection + * + * @param newFilter List of label indexes to filter on + */ + +void ModelsList::setFilteredLabels(std::set newFilter) +{ + filtlbls = std::move(newFilter); + storageDirty(EE_LABELS); +} + +/** + * @brief Returns all models that match the selected labels + * + * @param lbls Labels to search + * @return ModelsVector aka vector of all models matching the + * label filters + */ + +ModelsVector ModelsList::getFilteredModelList(const LabelsVector &lbls) +{ + if (lbls.size() == 0) return ModelsVector(); + + // Requesting only Unlabeled models + if (lbls.size() == 1 && lbls.at(0) == STR_UNLABELEDMODEL) + return getUnlabeledModels(); + + ModelsVector rv; + + for (const auto &mdl : *this) { + bool hasAllLabels = true; + bool hasAnyLabels = false; + bool favLabelIncluded = false; + bool hasFavLabel = false; + for (const auto &lbl : lbls) { + if (lbl == STR_UNLABELEDMODEL) // If requesting unlabeled model ignore it + break; + bool hasLabel = mdl->hasLabel(lbl); + if (lbl == STR_FAVORITE_LABEL) { + favLabelIncluded = true; + hasFavLabel = hasLabel; + } else { + if (hasLabel) { + hasAnyLabels = true; + } else { + hasAllLabels = false; + } + } + } + if (favLabelIncluded) { + if (g_eeGeneral.favMultiMode == 0) { + hasAnyLabels = hasAnyLabels && hasFavLabel; + hasAllLabels = hasAllLabels && hasFavLabel; + } else if (g_eeGeneral.favMultiMode == 1) { + hasAnyLabels = hasAnyLabels || hasFavLabel; + hasAllLabels = hasAllLabels && hasFavLabel; + } + } + if (((g_eeGeneral.labelMultiMode == 0) && hasAllLabels) || + ((g_eeGeneral.labelMultiMode == 1) && hasAnyLabels)) + rv.push_back(mdl); + } + + sortModelsBy(rv, sortOrder()); + return rv; +} diff --git a/radio/src/storage/modelslist.h b/radio/src/storage/modelslist.h index e62487e6e..8308392ec 100644 --- a/radio/src/storage/modelslist.h +++ b/radio/src/storage/modelslist.h @@ -21,66 +21,25 @@ #pragma once -#include - #include #include #include -#include #include #include -#include #include #include "sdcard.h" -#include "dataconstants.h" #include "rtc.h" #define DEFAULT_MODEL_SORT NAME_ASC +// Characters not (currently) allowed in labels +constexpr const char* labelExcludedChars = "\\\":'-"; + struct ModelHeader; struct ModuleData; -struct SimpleModuleData { - uint8_t type = 0; - uint8_t subType = 0; -}; - -typedef struct PACKED { - FSIZE_t fsize; /* File size */ - WORD fdate; /* Modified date */ - WORD ftime; /* Modified time */ -} FInfoH; - -#define FILE_HASH_LENGTH (sizeof(FInfoH) * 2) // Hex string output - -class ModelCell -{ - public: - char modelFilename[LEN_MODEL_FILENAME + 1] = ""; - char modelName[LEN_MODEL_NAME + 1] = ""; - char modelFinfoHash[FILE_HASH_LENGTH + 1] = ""; -#if LEN_BITMAP_NAME > 0 - char modelBitmap[LEN_BITMAP_NAME + 1] = ""; -#endif - gtime_t lastOpened = 0; - bool _isDirty = true; - - bool valid_rfData; - uint8_t modelId[NUM_MODULES] = {0, 0}; - SimpleModuleData moduleData[NUM_MODULES]; - - explicit ModelCell(const char *fileName); - - void setModelName(char *name); - void setRfData(ModelHeader *header, ModuleData* moduleData); - - void setRfModuleData(uint8_t moduleIdx, ModuleData *modData); -}; - -typedef std::vector LabelsVector; -typedef std::vector ModelsVector; typedef enum { NO_SORT, NAME_ASC, @@ -90,116 +49,101 @@ typedef enum { SORT_COUNT } ModelsSortBy; -/** - * @brief ModelMap is a multimap of all models and their cooresponding - * labels. Lables are referenced by index, stored in var labels - */ +struct SimpleModuleData { + uint8_t type = 0; + uint8_t subType = 0; +}; -class ModelMap : protected std::multimap +// Must match start of the FILINFO struct +typedef struct PACKED { + FSIZE_t fsize; /* File size */ + WORD fdate; /* Modified date */ + WORD ftime; /* Modified time */ +} FInfoH; + +#define FILE_HASH_LENGTH (sizeof(FInfoH) * 2) // Hex string output + +typedef std::vector LabelsVector; + +struct LabelsMap { public: - ModelsVector getUnlabeledModels(); - ModelsVector getAllModels(); - ModelsVector getModelsByLabel(const std::string &); - ModelsVector getModelsInLabels(const LabelsVector &lbls); - LabelsVector getLabelsByModel(ModelCell *); - std::map getSelectedLabels(ModelCell *); - bool isLabelSelected(const std::string &, ModelCell *); + LabelsMap() {} + + void addLabel(uint8_t idx); + void removeLabel(uint8_t idx); + bool deleteLabel(uint8_t idx); + void moveLabelUp(uint8_t idx); + bool hasLabel(uint8_t idx); LabelsVector getLabels(); - int addLabel(std::string lbl); - bool addLabelToModel(const std::string &, ModelCell *, bool update = false); - bool removeLabelFromModel(const std::string &label, ModelCell *, - bool update = false); - bool removeLabel( - const std::string &, - std::function progress = nullptr); - bool moveLabelTo(unsigned current, unsigned newind); - bool renameLabel(const std::string &from, std::string to, - std::function progress = nullptr); - std::string getCurrentLabel() { return currentlabel; }; - std::string getBulletLabelString(ModelCell *, const char *noresults = ""); - void setDirty(bool save = false); - void resetDirty() { _isDirty = false; } - bool isDirty() { return _isDirty; } - - // Currently selected labels in the GUI - void setFilteredLabels(std::set filtlbls) - { - this->filtlbls = std::move(filtlbls); - } - void clearFilter() { filtlbls.clear(); } - void addFilteredLabel(const std::string &lbl); - bool isLabelFiltered(const std::string &lbl); - std::set filteredLabels() { return filtlbls; } - - void setSortOrder(ModelsSortBy sortby); - ModelsSortBy sortOrder() {return _sortOrder;} - - static std::string toCSV(const LabelsVector &labels); - static LabelsVector fromCSV(const char *str); - static void escapeCSV(std::string &str); - static void unEscapeCSV(std::string &str); - static void removeYAMLChars(std::string &str); - - void clear() - { - _isDirty = true; - labels.clear(); - std::multimap::clear(); - } - - void updateModelCell(ModelCell *); - bool removeModels(ModelCell *); - - bool writeModelLabels(ModelCell*, const char*); protected: - ModelsSortBy _sortOrder = DEFAULT_MODEL_SORT; - bool _isDirty = true; - std::set filtlbls; - std::string currentlabel = ""; - - bool updateModelFile(ModelCell *); - void sortModelsBy(ModelsVector &mv, ModelsSortBy sortby); - - int getIndexByLabel(const std::string &str) - { - auto a = std::find(labels.begin(), labels.end(), str); - return a == labels.end() ? -1 : a - labels.begin(); - } - - std::string getLabelByIndex(uint16_t index) - { - if (index < (uint16_t)labels.size()) - return labels.at(index); - else - return std::string(); - } - - private: - LabelsVector labels; // Storage space for discovered labels + std::vector labelMap; }; +class ModelCell +{ + public: + ModelCell(const char *fileName); + + char modelFilename[LEN_MODEL_FILENAME + 1] = ""; + char modelName[LEN_MODEL_NAME + 1] = ""; + char modelFinfoHash[FILE_HASH_LENGTH + 1] = ""; +#if LEN_BITMAP_NAME > 0 + char modelBitmap[LEN_BITMAP_NAME + 1] = ""; +#endif + gtime_t lastOpened = 0; + bool _isDirty = true; + bool valid_rfData = false; + uint8_t modelId[NUM_MODULES] = {0, 0}; + SimpleModuleData moduleData[NUM_MODULES]; + + void setModelName(const char *name); + void setDefaultName(); + void setUniqueName(); + void setRfData(ModelHeader *header, ModuleData* moduleData); + + void addLabel(uint8_t idx); + void removeLabel(uint8_t idx); + void deleteLabel(uint8_t idx); + void moveLabelUp(uint8_t idx); + bool hasLabel(uint8_t idx) { return labelsMap.hasLabel(idx); } + bool hasLabel(const std::string& label); + + void updateModelFile(); + void updateModelCell(); + void updateFinfoHash(); + + void duplicateFrom(ModelCell* src); + + bool writeModelLabels(const char*); + + LabelsVector getLabels() { return labelsMap.getLabels(); } + + std::string getBulletLabelString(const char *noresults = ""); + + protected: + LabelsMap labelsMap; +}; + +typedef std::vector ModelsVector; + class ModelsList : public ModelsVector { - bool loaded; - - ModelCell *currentModel; - - void init(); - public: ModelsList(); ~ModelsList(); bool load(); - const char *save(LabelsVector newOrder=LabelsVector()); + const char *save(); void clear(); void setCurrentModel(ModelCell *cell); void updateCurrentModelCell(); - ModelCell *getCurrentModel() const { return currentModel; } + ModelCell* getCurrentModel() const { return currentModel; } + ModelCell* getModel(const char* filename); + ModelCell* getModelWithName(const char* name); ModelCell *addModel(const char *name, bool save = true, ModelCell *copyCell = nullptr); bool removeModel(ModelCell *model); @@ -207,19 +151,43 @@ class ModelsList : public ModelsVector bool isModelIdUnique(uint8_t moduleIdx, char *warn_buf, size_t warn_buf_len); uint8_t findNextUnusedModelId(uint8_t moduleIdx); - typedef struct _filedat { - std::string name; - char hash[FILE_HASH_LENGTH + 1]; - bool curmodel = false; - bool celladded = false; - } filedat; - std::vector fileHashInfo; + void setSortOrder(ModelsSortBy sortby); + ModelsSortBy sortOrder() {return _sortOrder;} + + void sortModelsBy(ModelsVector &mv, ModelsSortBy sortby); + + ModelsVector getUnlabeledModels(); + ModelsVector getAllModels(); + ModelsVector getFilteredModelList(const LabelsVector &lbls); + + int getIndexByLabel(const std::string &str); + std::string getLabelByIndex(uint16_t index); + const LabelsVector& getLabels() const { return labels; } + + int addLabel(std::string lbl); + void removeLabel(const std::string &, + std::function progress = nullptr); + void moveLabelUp(uint16_t idx); + void renameLabel(const std::string &from, const std::string& to, + std::function progress = nullptr); + + bool addLabelToModel(const std::string &, ModelCell *, bool update = true); + void addLabelsToModel(const char* labels, ModelCell* cell); + void removeLabelFromModel(const std::string &, ModelCell *); + + void setFilteredLabels(std::set filtlbls); + void clearFilter() { filtlbls.clear(); } + void addFilteredLabel(const std::string &lbl); + bool isLabelFiltered(const std::string &lbl); + std::set filteredLabels() { return filtlbls; } protected: - FIL file; + ModelCell *currentModel = nullptr; + ModelsSortBy _sortOrder = DEFAULT_MODEL_SORT; + LabelsVector labels; // Storage space for discovered labels + std::set filtlbls; bool loadYaml(); }; -extern ModelsList modelslist; -extern ModelMap modelslabels; +extern ModelsList modelCellManager; diff --git a/radio/src/storage/sdcard_common.cpp b/radio/src/storage/sdcard_common.cpp index b1a90db63..a379757fb 100644 --- a/radio/src/storage/sdcard_common.cpp +++ b/radio/src/storage/sdcard_common.cpp @@ -114,7 +114,7 @@ void storageCheck(bool immediately) if (storageDirtyMsk & EE_LABELS) { if (retryLabelsCount < retryLimit) { TRACE("SD card write labels"); - const char * error = modelslist.save(); + const char * error = modelCellManager.save(); if (error) { TRACE("writeLabels error=%s", error); retryLabelsCount += 1; @@ -137,7 +137,7 @@ void storageCheck(bool immediately) TRACE("SD card write model settings"); const char * error = writeModel(); #if defined(STORAGE_MODELSLIST) - modelslist.updateCurrentModelCell(); + modelCellManager.updateCurrentModelCell(); #endif if (error) { TRACE("writeModel error=%s", error); @@ -206,7 +206,7 @@ void storageReadAll() #if defined(STORAGE_MODELSLIST) // Wipe models list in case // it's being reloaded after USB connection - modelslist.clear(); + modelCellManager.clear(); #endif // Some radio defaults overriden by config loading: @@ -245,7 +245,7 @@ void storageReadAll() #if defined(STORAGE_MODELSLIST) // and reload the list - modelslist.load(); + modelCellManager.load(); // Current model filename is empty... // Let's fix it! diff --git a/radio/src/storage/sdcard_yaml.cpp b/radio/src/storage/sdcard_yaml.cpp index e37f5508f..6575e438f 100644 --- a/radio/src/storage/sdcard_yaml.cpp +++ b/radio/src/storage/sdcard_yaml.cpp @@ -410,7 +410,9 @@ void getModelNumberStr(uint8_t idx, char* model_idx) const char * writeModel() { #if defined(STORAGE_MODELSLIST) - return writeModelYaml(g_eeGeneral.currModelFilename); + auto rv = writeModelYaml(g_eeGeneral.currModelFilename); + modelCellManager.getCurrentModel()->updateFinfoHash(); + return rv; #else char fname[MODELIDX_STRLEN + sizeof(YAML_EXT)]; getModelNumberStr(g_eeGeneral.currModel, fname); diff --git a/radio/src/storage/yaml/yaml_labelslist.cpp b/radio/src/storage/yaml/yaml_labelslist.cpp index 1a20d208d..70bab49a9 100644 --- a/radio/src/storage/yaml/yaml_labelslist.cpp +++ b/radio/src/storage/yaml/yaml_labelslist.cpp @@ -23,11 +23,9 @@ #include "yaml_parser.h" #include "debug.h" - +#include "strhelpers.h" #include "storage/modelslist.h" -#include - #if defined(_WIN32) || defined(_WIN64) #define strcasecmp _stricmp #define strncasecmp _strnicmp @@ -39,23 +37,21 @@ #define TRACE_LABELS_YAML(...) #endif -using std::list; - struct labelslist_iter { - enum SectionType { - SEC_Unknown=0, - SEC_Labels=1, - SEC_Models=2, - SEC_Sort=3, - }; + enum SectionType { + SEC_Unknown=0, + SEC_Labels=1, + SEC_Models=2, + SEC_Sort=3, + }; - ModelCell *curmodel; - bool modeldatavalid; // Used to determine if reading yaml values is necessary - uint8_t level; - uint8_t section; - char current_attr[LABELS_LENGTH+1]; // set after find_node() - char current_label[LABELS_LENGTH+1]; // set after find_node() + ModelCell *curmodel; + bool modeldatavalid; // Used to determine if reading yaml values is necessary + uint8_t level; + uint8_t section; + char current_attr[LABELS_LENGTH+1]; // set after find_node() + char current_label[LABELS_LENGTH+1]; // set after find_node() }; static labelslist_iter __labelslist_iter_inst; @@ -132,28 +128,11 @@ static bool find_node(void* ctx, const char* buf, uint8_t len) // Model List if(mi->level == 1 && mi->section == labelslist_iter::SEC_Models) { - bool found=false; - for(auto &filehash : modelslist.fileHashInfo) { - if(filehash.name == mi->current_attr) { - TRACE_LABELS_YAML(" Model %s has a real file, creating a modelcell", mi->current_attr); - if(filehash.celladded) { - TRACE_LABELS_YAML(" Duplicate found labels.yml model cell %s already added", mi->current_attr); - break; - } - ModelCell *model = new ModelCell(mi->current_attr); - strcpy(model->modelFinfoHash, filehash.hash); - modelslist.push_back(model); - filehash.celladded = true; - if(filehash.curmodel == true) - modelslist.setCurrentModel(model); - mi->curmodel = model; - mi->modeldatavalid = false; - mi->curmodel->_isDirty = true; - found = true; - break; - } - } - if(!found) { + auto model = modelCellManager.getModel(mi->current_attr); + if (model) { + mi->curmodel = model; + mi->modeldatavalid = false; + } else { mi->curmodel = NULL; TRACE_LABELS_YAML("File does not exist in /MODELS"); } @@ -162,9 +141,8 @@ static bool find_node(void* ctx, const char* buf, uint8_t len) // Labels List if(mi->level == 1 && mi->section == labelslist_iter::SEC_Labels) { TRACE_LABELS_YAML("Label Found -- %s", mi->current_attr); - modelslabels.addLabel(mi->current_attr); - strncpy(mi->current_label,mi->current_attr, LABELS_LENGTH); - mi->current_label[LABELS_LENGTH] = '\0'; + modelCellManager.addLabel(mi->current_attr); + strAppend(mi->current_label, mi->current_attr, LABELS_LENGTH); } return true; @@ -194,14 +172,10 @@ static void set_attr(void* ctx, const char* buf, uint16_t len) mi->curmodel->_isDirty = false; } else { TRACE_LABELS_YAML("FILE HASH Does not Match, Open model and rebuild modelcell"); - mi->modeldatavalid = false; - mi->curmodel->_isDirty = true; } } // Last Opened - // Hack: Always load this as the hash value is (currently) not in synch with the saved model file - // TODO: Ensure stored hash is up to date. if (!strcasecmp(mi->current_attr, "lastopen")) { mi->curmodel->lastOpened = (gtime_t)strtol(value, NULL, 0); TRACE_LABELS_YAML(" Last Opened %lu", value); @@ -223,11 +197,7 @@ static void set_attr(void* ctx, const char* buf, uint16_t len) // Model Labels } else if(!strcasecmp(mi->current_attr, "labels")) { - LabelsVector labels = ModelMap::fromCSV(value); - for(const auto &lbl : labels ) { - modelslabels.addLabelToModel(lbl,mi->curmodel); - TRACE_LABELS_YAML(" Adding the label - %s", lbl.c_str()); - } + modelCellManager.addLabelsToModel(value, mi->curmodel); // RF Module Data } else { @@ -257,18 +227,15 @@ static void set_attr(void* ctx, const char* buf, uint16_t len) // Label Section } else if(mi->level == 2 && mi->section == labelslist_iter::SEC_Labels) { - if(!strcasecmp(mi->current_attr, "icon")) { - TRACE_LABELS_YAML(" Label Icon - %s", value); - // TODO - Check icon exists, or ignore it. - } else if(!strcasecmp(mi->current_attr, "selected")) { + if(!strcasecmp(mi->current_attr, "selected")) { TRACE("FOUND %s Label is selected", mi->current_label); - modelslabels.addFilteredLabel(mi->current_label); + modelCellManager.addFilteredLabel(mi->current_label); } // Sort Order } else if (mi->level == 0 && mi->section == labelslist_iter::SEC_Sort) { TRACE_LABELS_YAML(" Sort Order Found -- %s", value); - modelslabels.setSortOrder((ModelsSortBy)strtol(value,NULL,10)); + modelCellManager.setSortOrder((ModelsSortBy)strtol(value,NULL,10)); } } diff --git a/radio/src/tests/modelslist.cpp b/radio/src/tests/modelslist.cpp index 3f23eea4f..d70440bd5 100644 --- a/radio/src/tests/modelslist.cpp +++ b/radio/src/tests/modelslist.cpp @@ -137,7 +137,7 @@ TEST(PartialModel, HeaderParsesIdenticallyToModelData) // Behavioural contract for ModelHeader::labels: it holds the CSV-joined // list of *every* label attached to a model (declared as -// char[LABELS_LENGTH], currently 100 bytes) -- as distinct from the length +// char[LABELS_LENGTH+1], currently 100 bytes) -- as distinct from the length // of one individual label name (LABEL_LENGTH, 16 bytes). A model with // several labels attached should keep all of them when this field gets // (re)written, up to the field's own declared capacity, not just as much @@ -156,16 +156,16 @@ TEST(PartialModel, LabelsFieldRetainsFullCsvUpToItsOwnCapacity) ASSERT_GT(strlen(csv), (size_t)LABEL_LENGTH); ASSERT_LT(strlen(csv), (size_t)LABELS_LENGTH); - strAppend(partial.header.labels, csv, LABELS_LENGTH - 1); + strAppend(partial.header.labels, csv, LABELS_LENGTH); EXPECT_STREQ(csv, partial.header.labels) << "header.labels should retain the full label list up to " - "LABELS_LENGTH-1 characters, not just a single label name's " + "LABELS_LENGTH characters, not just a single label name's " "worth."; } #endif // defined(STORAGE_MODELSLIST) -// Real FatFS round-trip tests for ModelMap::writeModelLabels() itself - +// Real FatFS round-trip tests for ModelCell::writeModelLabels() itself - // the raw file-surgery function these PartialModel tests don't exercise. #if defined(SIMU) && defined(STORAGE_MODELSLIST) @@ -198,8 +198,6 @@ static size_t modelFileSize(const char* modelFilename) // model file. TEST(ModelsList, WriteModelLabelsPreservesBodySize) { - ModelMap map; - const char* fileA = "wml_test_a.yml"; const char* fileB = "wml_test_b.yml"; @@ -219,8 +217,8 @@ TEST(ModelsList, WriteModelLabelsPreservesBodySize) ModelCell cellA(fileA); ModelCell cellB(fileB); - EXPECT_TRUE(map.writeModelLabels(&cellA, "NewLabel")); - EXPECT_TRUE(map.writeModelLabels(&cellB, "NewLabel")); + EXPECT_TRUE(cellA.writeModelLabels("NewLabel")); + EXPECT_TRUE(cellB.writeModelLabels("NewLabel")); size_t sizeA = modelFileSize(fileA); size_t sizeB = modelFileSize(fileB); @@ -235,13 +233,12 @@ TEST(ModelsList, WriteModelLabelsPreservesBodySize) TEST(ModelsList, WriteModelLabelsFailsCleanlyWithoutHeader) { - ModelMap map; const char* file = "wml_test_noheader.yml"; writeRawModelFile(file, "notheader:\n foo: bar\n"); ModelCell cell(file); - EXPECT_FALSE(map.writeModelLabels(&cell, "NewLabel")); + EXPECT_FALSE(cell.writeModelLabels("NewLabel")); std::filesystem::remove(simuFatfsGetRealPath(std::string(MODELS_PATH) + "/" + file)); } diff --git a/radio/src/tests/modelslist_labels.cpp b/radio/src/tests/modelslist_labels.cpp index 50f943da7..e8547ecba 100644 --- a/radio/src/tests/modelslist_labels.cpp +++ b/radio/src/tests/modelslist_labels.cpp @@ -30,7 +30,7 @@ // // Label management (renaming/adding/removing labels) routinely touches // models other than the currently loaded one. These tests exercise that -// label-management path through its public ModelMap API and assert the +// label-management path through its public modelCellManager API and assert the // currently loaded model's screen/topbar data is left alone, regardless of // what label edits are made to other models on disk. @@ -48,7 +48,7 @@ namespace fs = std::filesystem; -class ModelMapFsTest : public ::testing::Test +class ModelLabelsFsTest : public ::testing::Test { protected: fs::path scratchDir; @@ -56,7 +56,7 @@ class ModelMapFsTest : public ::testing::Test void SetUp() override { scratchDir = fs::temp_directory_path() / - fs::path("edgetx-gtest-modelslist-labels"); + fs::path("edgetx-gtest-modelCellManager-labels"); std::error_code ec; fs::remove_all(scratchDir, ec); fs::create_directories(scratchDir / "MODELS", ec); @@ -64,15 +64,13 @@ class ModelMapFsTest : public ::testing::Test simuFatfsSetPaths(scratchDir.string().c_str(), nullptr); - modelslist.clear(); - modelslabels.clear(); + modelCellManager.clear(); memclear(&g_model, sizeof(g_model)); } void TearDown() override { - modelslist.clear(); - modelslabels.clear(); + modelCellManager.clear(); simuFatfsSetPaths(TESTS_PATH, nullptr); @@ -100,7 +98,7 @@ class ModelMapFsTest : public ::testing::Test // a "header:" section at all. Giving it a name keeps the fixture // realistic and avoids that unrelated edge case. copyToUnTerminated(model.header.name, filename); - strAppend(model.header.labels, labels, LABELS_LENGTH - 1); + strAppend(model.header.labels, labels, LABELS_LENGTH); model.getScreenData(0)->LayoutId = screenLayoutId; model.getTopbarData()->zones[0].widgetName = widgetName; @@ -112,25 +110,25 @@ class ModelMapFsTest : public ::testing::Test } }; -TEST_F(ModelMapFsTest, RenamingLabelOnOtherModelLeavesActiveScreenDataUntouched) +TEST_F(ModelLabelsFsTest, RenamingLabelOnOtherModelLeavesActiveScreenDataUntouched) { // Seed a second, non-active model on disk with a label and screen/topbar // data distinct from the active model's. writeFixtureModel("model0002.yml", "Foo", "OtherLayout", "OtherWidget"); - ModelCell* other = modelslist.addModel("model0002.yml", false); + ModelCell* other = modelCellManager.addModel("model0002.yml", false); ASSERT_NE(other, nullptr); - ASSERT_FALSE(modelslabels.addLabelToModel("Foo", other, false)); + ASSERT_FALSE(modelCellManager.addLabelToModel("Foo", other, false)); // Now "load" the active model: register its cell and mark it current, and // set its screen/topbar data to its own values. - ModelCell* active = modelslist.addModel("model0001.yml", false); + ModelCell* active = modelCellManager.addModel("model0001.yml", false); ASSERT_NE(active, nullptr); - modelslist.setCurrentModel(active); + modelCellManager.setCurrentModel(active); g_model.getScreenData(0)->LayoutId = "ActiveLayout"; g_model.getTopbarData()->zones[0].widgetName = "ActiveWidget"; // Rename a label that only exists on the other, non-active model. - modelslabels.renameLabel("Foo", "Bar"); + modelCellManager.renameLabel("Foo", "Bar"); // The active model's screen/topbar data must be unaffected by editing an // unrelated model's labels. @@ -147,22 +145,22 @@ TEST_F(ModelMapFsTest, RenamingLabelOnOtherModelLeavesActiveScreenDataUntouched) EXPECT_STREQ(partial.header.labels, "Bar"); } -TEST_F(ModelMapFsTest, +TEST_F(ModelLabelsFsTest, AddingLabelToOtherModelWithFileUpdateLeavesActiveScreenDataUntouched) { writeFixtureModel("model0002.yml", "", "OtherLayout", "OtherWidget"); - ModelCell* other = modelslist.addModel("model0002.yml", false); + ModelCell* other = modelCellManager.addModel("model0002.yml", false); ASSERT_NE(other, nullptr); - ModelCell* active = modelslist.addModel("model0001.yml", false); + ModelCell* active = modelCellManager.addModel("model0001.yml", false); ASSERT_NE(active, nullptr); - modelslist.setCurrentModel(active); + modelCellManager.setCurrentModel(active); g_model.getScreenData(0)->LayoutId = "ActiveLayout"; g_model.getTopbarData()->zones[0].widgetName = "ActiveWidget"; - // update=true drives ModelMap::updateModelFile(), the second call site + // update=true drives ModelCell::updateModelFile(), the second call site // that reads/writes a non-active model's file on disk. - EXPECT_FALSE(modelslabels.addLabelToModel("Baz", other, true)); + EXPECT_FALSE(modelCellManager.addLabelToModel("Baz", other, true)); EXPECT_STREQ(g_model.getScreenData(0)->LayoutId.c_str(), "ActiveLayout"); EXPECT_STREQ(g_model.getTopbarData()->zones[0].widgetName.c_str(), diff --git a/radio/src/tests/modelslist_manager.cpp b/radio/src/tests/modelslist_manager.cpp new file mode 100644 index 000000000..c99335a1b --- /dev/null +++ b/radio/src/tests/modelslist_manager.cpp @@ -0,0 +1,1057 @@ +/* + * Copyright (C) EdgeTX + * + * Based on code named + * opentx - https://github.com/opentx/opentx + * th9x - http://code.google.com/p/th9x + * er9x - http://code.google.com/p/er9x + * gruvin9x - http://code.google.com/p/gruvin9x + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ + +// Behavioural tests for the ModelCell / ModelsList (modelCellManager) label +// and duplicate-name management: unique-name generation, label rename / +// delete / reorder, LabelsMap index bookkeeping, and labels.yml round trips. + +#include "gtests.h" +#include "location.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "storage/modelslist.h" +#include "storage/sdcard_common.h" +#include "storage/sdcard_yaml.h" +#include "storage/storage.h" +#include "storage/yaml/yaml_datastructs.h" + +#if defined(COLORLCD) + +#include "dialog.h" +#include "lcd.h" +#include "mainwindow.h" +#include "view_main.h" + +namespace fs = std::filesystem; + +namespace +{ + +std::vector sorted(LabelsVector v) +{ + std::sort(v.begin(), v.end()); + return v; +} + +std::vector sorted(std::initializer_list l) +{ + std::vector v(l.begin(), l.end()); + std::sort(v.begin(), v.end()); + return v; +} + +std::vector splitCSV(const char* csv) +{ + std::vector out; + std::string cur; + for (const char* c = csv; *c; ++c) { + if (*c == ',') { + out.push_back(cur); + cur.clear(); + } else { + cur += *c; + } + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +std::string joinCSV(const std::vector& v) +{ + std::string s; + for (auto& e : v) { + if (!s.empty()) s += ','; + s += e; + } + return s; +} + +int countOf(const LabelsVector& v, const std::string& s) +{ + return (int)std::count(v.begin(), v.end(), s); +} + +// Split a model yaml file into its "header:" block and everything else +void splitModelYaml(const std::string& text, std::string& header, + std::string& body) +{ + std::istringstream in(text); + std::string line; + bool inHeader = false, done = false; + while (std::getline(in, line)) { + std::string bare = line; + while (!bare.empty() && (bare.back() == '\r' || bare.back() == ' ')) + bare.pop_back(); + if (!done && bare == "header:") { + inHeader = true; + header += line + "\n"; + continue; + } + if (inHeader && !done) { + if (!bare.empty() && bare[0] == ' ') { + header += line + "\n"; + continue; + } + done = true; + } + body += line + "\n"; + } +} + +} // namespace + +class ModelCellManagerFsTest : public ::testing::Test +{ + protected: + fs::path scratchDir; + + void SetUp() override + { + scratchDir = fs::temp_directory_path() / + fs::path("edgetx-gtest-modelcellmanager"); + std::error_code ec; + fs::remove_all(scratchDir, ec); + fs::create_directories(scratchDir / "MODELS", ec); + ASSERT_FALSE(ec) << "could not create scratch MODELS directory"; + + simuFatfsSetPaths(scratchDir.string().c_str(), nullptr); + + modelCellManager.clear(); + modelCellManager.clearFilter(); + modelCellManager.setSortOrder(DEFAULT_MODEL_SORT); + memclear(&g_model, sizeof(g_model)); + memclear(g_eeGeneral.currModelFilename, sizeof(g_eeGeneral.currModelFilename)); + storageDirtyMsk = 0; + } + + void TearDown() override + { + modelCellManager.clear(); + modelCellManager.clearFilter(); + modelCellManager.setSortOrder(DEFAULT_MODEL_SORT); + storageDirtyMsk = 0; + + simuFatfsSetPaths(TESTS_PATH, nullptr); + + std::error_code ec; + fs::remove_all(scratchDir, ec); + } + + // ---- helpers ----------------------------------------------------------- + + static std::string realPath(const char* file) + { + return simuFatfsGetRealPath(std::string(MODELS_PATH) + "/" + file); + } + + static void setCurrentFilename(const char* file) + { + strAppend(g_eeGeneral.currModelFilename, file, LEN_MODEL_FILENAME); + } + + // Add an in-memory cell (no file on disk needed). + ModelCell* addCell(const char* file, const char* name, + std::initializer_list labels = {}) + { + ModelCell* cell = modelCellManager.addModel(file, false); + cell->setModelName(name); + for (auto l : labels) modelCellManager.addLabelToModel(l, cell, false); + return cell; + } + + // Write a model yaml file to MODELS/. + void writeFixtureModel(const char* file, const char* name, + const char* labels) + { + ModelData model; + memclear(&model, sizeof(model)); + strAppend(model.header.name, name, LEN_MODEL_NAME); + strAppend(model.header.labels, labels, LABELS_LENGTH); + + char path[256]; + getModelPath(path, file); + ASSERT_EQ(writeFileYaml(path, get_modeldata_nodes(), (uint8_t*)&model, 0), + (const char*)nullptr) + << "failed writing fixture model " << file; + } + + static PartialModel readHeader(const char* file) + { + PartialModel partial; + memclear(&partial, sizeof(partial)); + readModelYaml(file, (uint8_t*)&partial, sizeof(PartialModel)); + return partial; + } + + static std::string readFile(const char* file) + { + std::ifstream in(realPath(file), std::ios::binary); + std::stringstream ss; + ss << in.rdbuf(); + return ss.str(); + } + + static std::string bodyOf(const char* file) + { + std::string h, b; + splitModelYaml(readFile(file), h, b); + return b; + } + + static LabelsVector labelsOf(const char* file) + { + ModelCell* c = modelCellManager.getModel(file); + return c ? c->getLabels() : LabelsVector(); + } + + // Register labels "L0".."L" in the manager (index == number) + static void registerLabels(int n) + { + modelCellManager.clear(); + for (int i = 0; i < n; i++) + modelCellManager.addLabel("L" + std::to_string(i)); + } + + static std::string fileName(int n) + { + char fn[32]; + snprintf(fn, sizeof(fn), "model%04d.yml", n); + return fn; + } + + // Duplicate a cell `copies` times the way the model page does and check + // every generated name is unique and fits. + void checkDuplicateNames(size_t len, int copies) + { + std::string name = std::string("ABCDEFGHIJKLMNO").substr(0, len); + ModelCell* src = addCell("model0001.yml", name.c_str()); + ASSERT_STREQ(src->modelName, name.c_str()); + + for (int n = 1; n <= copies; n++) { + std::string fn = fileName(n + 1); + ModelCell* dup = modelCellManager.addModel(fn.c_str(), false, src); + dup->setUniqueName(); + + size_t l = strlen(dup->modelName); + EXPECT_LE(l, (size_t)LEN_MODEL_NAME) << "len " << len << " copy " << n; + EXPECT_GT(l, 0u); + EXPECT_EQ(dup->modelName[l - 1], ')') + << "len " << len << " copy " << n << " -> " << dup->modelName; + + int same = 0; + for (auto* m : modelCellManager) + if (strcmp(m->modelName, dup->modelName) == 0) same++; + EXPECT_EQ(same, 1) << "duplicate name " << dup->modelName; + } + } +}; + +// --------------------------------------------------------------------------- +// Default / unique names +// --------------------------------------------------------------------------- + +TEST(ModelCellName, DefaultNameFromNumericFilename) +{ + ModelCell a("model0003.yml"); + a.setModelName(""); + EXPECT_STREQ(a.modelName, "MODEL03"); + + ModelCell b("model12.yml"); + b.setModelName(nullptr); + EXPECT_STREQ(b.modelName, "MODEL12"); + + ModelCell c("custom.yml"); + c.setModelName(""); + EXPECT_STREQ(c.modelName, "custom"); + + ModelCell d("model0003.yml"); + d.setModelName("Named"); + EXPECT_STREQ(d.modelName, "Named"); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameFirstDuplicate) +{ + ModelCell* src = addCell("model0001.yml", "Alpha"); + ModelCell* dup = modelCellManager.addModel("model0002.yml", false, src); + EXPECT_STREQ(dup->modelName, "Alpha"); // copied verbatim until made unique + dup->setUniqueName(); + EXPECT_STREQ(dup->modelName, "Alpha(1)"); + EXPECT_STREQ(src->modelName, "Alpha"); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameSkipsTakenSuffixes) +{ + ModelCell* src = addCell("model0001.yml", "Alpha"); + addCell("model0002.yml", "Alpha(1)"); + addCell("model0003.yml", "Alpha(2)"); + ModelCell* dup = modelCellManager.addModel("model0004.yml", false, src); + dup->setUniqueName(); + EXPECT_STREQ(dup->modelName, "Alpha(3)"); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameShortNameStaysWithinBounds) +{ + checkDuplicateNames(1, 12); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameElevenCharNameStaysWithinBounds) +{ + checkDuplicateNames(11, 12); +} + +// A name of exactly LEN_MODEL_NAME - 3 characters is not truncated, so from +// the 10th duplicate on "(10)" no longer fits. The overflow lands inside the +// ModelCell / stack frame, so it shows up as a 16 character name here rather +// than (necessarily) as an ASan report. +TEST_F(ModelCellManagerFsTest, UniqueNameTwelveCharNameTenthDuplicate) +{ + checkDuplicateNames(12, 12); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameThirteenCharNameStaysWithinBounds) +{ + checkDuplicateNames(13, 12); +} + +TEST_F(ModelCellManagerFsTest, UniqueNameFullLengthNameStaysWithinBounds) +{ + checkDuplicateNames(LEN_MODEL_NAME, 12); +} + +// --------------------------------------------------------------------------- +// LabelsMap bookkeeping +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, LabelsMapInsertsInSortedOrderWithoutDuplicates) +{ + registerLabels(5); + LabelsMap m; + m.addLabel(3); + m.addLabel(1); + m.addLabel(2); + m.addLabel(2); + EXPECT_EQ(m.getLabels(), (LabelsVector{"L1", "L2", "L3"})); + EXPECT_TRUE(m.hasLabel(2)); + EXPECT_FALSE(m.hasLabel(0)); + EXPECT_FALSE(m.hasLabel(255)); +} + +TEST_F(ModelCellManagerFsTest, LabelsMapRemoveOnlyRemovesThatLabel) +{ + registerLabels(5); + LabelsMap m; + m.addLabel(1); + m.addLabel(3); + m.removeLabel(2); // not present: no-op + EXPECT_EQ(m.getLabels(), (LabelsVector{"L1", "L3"})); + m.removeLabel(1); + EXPECT_EQ(m.getLabels(), (LabelsVector{"L3"})); +} + +TEST_F(ModelCellManagerFsTest, LabelsMapDeleteShiftsHigherIndexes) +{ + registerLabels(5); + LabelsMap m; + m.addLabel(1); + m.addLabel(3); + + EXPECT_FALSE(m.deleteLabel(2)); // not present, but 3 shifts down to 2 + EXPECT_EQ(m.getLabels(), (LabelsVector{"L1", "L2"})); + + EXPECT_TRUE(m.deleteLabel(1)); // present: removed, nothing above left to shift but 2->1 + EXPECT_EQ(m.getLabels(), (LabelsVector{"L1"})); +} + +TEST_F(ModelCellManagerFsTest, LabelsMapMoveUpSwapsMembership) +{ + registerLabels(4); + LabelsMap both, first, second, neither; + both.addLabel(1); both.addLabel(2); + first.addLabel(1); + second.addLabel(2); + + both.moveLabelUp(2); + EXPECT_EQ(both.getLabels(), (LabelsVector{"L1", "L2"})); + + first.moveLabelUp(2); // 1 -> 2 + EXPECT_EQ(first.getLabels(), (LabelsVector{"L2"})); + + second.moveLabelUp(2); // 2 -> 1 + EXPECT_EQ(second.getLabels(), (LabelsVector{"L1"})); + + neither.moveLabelUp(2); + EXPECT_TRUE(neither.getLabels().empty()); + + second.moveLabelUp(0); // top label can't move + EXPECT_EQ(second.getLabels(), (LabelsVector{"L1"})); +} + +// --------------------------------------------------------------------------- +// addLabelToModel edge cases +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, RejectedLabelDoesNotMakeModelLabelled) +{ + ModelCell* cell = addCell("model0001.yml", "One"); + + // "Unlabeled" is reserved and a label made only of excluded characters is + // empty once sanitised: addLabel() refuses both, so nothing may be attached. + modelCellManager.addLabelToModel(STR_UNLABELEDMODEL, cell, false); + modelCellManager.addLabelToModel("\"", cell, false); + + EXPECT_TRUE(cell->getLabels().empty()); + auto unlabeled = modelCellManager.getUnlabeledModels(); + EXPECT_EQ(unlabeled.size(), 1u); +} + +TEST_F(ModelCellManagerFsTest, LabelsFieldLengthLimitIsEnforced) +{ + ModelCell* cell = addCell("model0001.yml", "One"); + int accepted = 0; + for (int i = 0; i < 20; i++) { + std::string lbl = std::string(15, 'A' + i); + if (!modelCellManager.addLabelToModel(lbl, cell, false)) accepted++; + } + EXPECT_GT(accepted, 0); + EXPECT_LT(accepted, 20); + std::string csv = joinCSV(cell->getLabels()); + EXPECT_LE(csv.size(), (size_t)LABELS_LENGTH); + EXPECT_EQ(cell->getLabels().size(), (size_t)accepted); +} + +// --------------------------------------------------------------------------- +// renameLabel +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, RenameLabelPatchesOtherModelHeaderKeepsBody) +{ + writeFixtureModel("model0001.yml", "One", "Keep"); + writeFixtureModel("model0002.yml", "Two", "Foo,Keep"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + ASSERT_EQ(sorted(labelsOf("model0002.yml")), sorted({"Foo", "Keep"})); + std::string bodyBefore = bodyOf("model0002.yml"); + ASSERT_FALSE(bodyBefore.empty()); + + modelCellManager.renameLabel("Foo", "Bar"); + + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"Bar", "Keep"})); + PartialModel hdr = readHeader("model0002.yml"); + EXPECT_EQ(sorted(splitCSV(hdr.header.labels)), sorted({"Bar", "Keep"})); + EXPECT_STREQ(hdr.header.name, "Two"); + EXPECT_EQ(bodyOf("model0002.yml"), bodyBefore); + EXPECT_EQ(modelCellManager.getIndexByLabel("Foo"), -1); + EXPECT_GE(modelCellManager.getIndexByLabel("Bar"), 0); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelOnCurrentModelUpdatesRamNotFile) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(modelCellManager.getCurrentModel(), + modelCellManager.getModel("model0001.yml")); + storageDirtyMsk = 0; + + modelCellManager.renameLabel("Foo", "Bar"); + + EXPECT_STREQ(g_model.header.labels, "Bar"); + EXPECT_TRUE(storageDirtyMsk & EE_MODEL); + // The file is only rewritten by the normal storage flush + EXPECT_STREQ(readHeader("model0001.yml").header.labels, "Foo"); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelToSameNameKeepsSingleLabel) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.renameLabel("Foo", "Foo"); + + EXPECT_EQ(countOf(modelCellManager.getLabels(), "Foo"), 1); + EXPECT_EQ(labelsOf("model0001.yml"), (LabelsVector{"Foo"})); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelOntoExistingLabelMerges) +{ + writeFixtureModel("model0001.yml", "One", "Bar"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.renameLabel("Foo", "Bar"); + + EXPECT_EQ(countOf(modelCellManager.getLabels(), "Bar"), 1) + << "label list now has duplicate entries"; + EXPECT_EQ(countOf(labelsOf("model0002.yml"), "Bar"), 1); + EXPECT_EQ(splitCSV(readHeader("model0002.yml").header.labels), + (std::vector{"Bar"})); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelRejectsYamlUnsafeCharacters) +{ + writeFixtureModel("model0001.yml", "One", ""); + writeFixtureModel("model0002.yml", "Two", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.renameLabel("Foo", "a\"b:c'd-e\\f"); + + for (const auto& lbl : modelCellManager.getLabels()) + for (const char* c = labelExcludedChars; *c; ++c) + EXPECT_EQ(lbl.find(*c), std::string::npos) + << "label '" << lbl << "' contains excluded char '" << *c << "'"; + + PartialModel hdr = readHeader("model0002.yml"); + std::string csv = hdr.header.labels; + for (const char* c = labelExcludedChars; *c; ++c) + EXPECT_EQ(csv.find(*c), std::string::npos) + << "model header labels '" << csv << "' contain '" << *c << "'"; +} + +TEST_F(ModelCellManagerFsTest, RenameLabelTruncatesToLabelLength) +{ + writeFixtureModel("model0001.yml", "One", ""); + writeFixtureModel("model0002.yml", "Two", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.renameLabel("Foo", std::string(40, 'x')); + + for (const auto& lbl : modelCellManager.getLabels()) + EXPECT_LE(lbl.size(), (size_t)LABEL_LENGTH) << "label '" << lbl << "'"; +} + +// Renaming must never leave a model's label field holding a partial label +// (the CSV is capped at LABELS_LENGTH characters). +TEST_F(ModelCellManagerFsTest, RenameLabelNeverWritesPartialLabels) +{ + std::vector many; + for (int i = 0; i < 6; i++) many.push_back(std::string(15, 'A' + i)); + + writeFixtureModel("model0001.yml", "One", ""); + writeFixtureModel("model0002.yml", "Two", joinCSV(many).c_str()); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(labelsOf("model0002.yml").size(), 6u); + + modelCellManager.renameLabel(many[0], std::string(30, 'Z')); + + auto known = modelCellManager.getLabels(); + for (const auto& tok : splitCSV(readHeader("model0002.yml").header.labels)) + EXPECT_EQ(countOf(known, tok), 1) + << "header holds '" << tok << "' which is not a known label"; +} + +TEST_F(ModelCellManagerFsTest, RenameLabelReportsProgressToCompletion) +{ + writeFixtureModel("model0001.yml", "One", ""); + writeFixtureModel("model0002.yml", "Two", "Foo"); + writeFixtureModel("model0003.yml", "Three", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + int calls = 0, last = -1; + modelCellManager.renameLabel("Foo", "Bar", [&](const char*, int pct) { + calls++; + last = pct; + }); + EXPECT_GT(calls, 0); + EXPECT_EQ(last, 100); +} + +// Callers close the progress dialog at 100% +TEST_F(ModelCellManagerFsTest, RenameLabelOntoExistingLabelProgressEndsAt100) +{ + writeFixtureModel("model0001.yml", "One", "Bar"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + writeFixtureModel("model0003.yml", "Three", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + std::vector reported; + modelCellManager.renameLabel("Foo", "Bar", [&](const char*, int pct) { + reported.push_back(pct); + }); + + auto done = std::find(reported.begin(), reported.end(), 100); + ASSERT_NE(done, reported.end()); + for (auto it = done; it != reported.end(); ++it) + EXPECT_EQ(*it, 100) << "progress reported again after reaching 100%"; +} + +static lv_obj_t* findProgressBar(lv_obj_t* obj) +{ + if (lv_obj_has_class(obj, &lv_bar_class)) return obj; + for (uint32_t i = 0; i < lv_obj_get_child_cnt(obj); i++) { + auto bar = findProgressBar(lv_obj_get_child(obj, i)); + if (bar) return bar; + } + return nullptr; +} + +// Callback as used in model_select.cpp. LVGL memory is not visible to ASAN, +// so check the progress bar is still valid before each update. +TEST_F(ModelCellManagerFsTest, RenameLabelOntoExistingLabelWithProgressDialog) +{ + writeFixtureModel("model0001.yml", "One", "Bar"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + writeFixtureModel("model0003.yml", "Three", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + // ProgressDialog forces a refresh, no simulator front end to complete it + lcdSetFlushCb([](lv_disp_drv_t* disp, uint16_t*, const rect_t&) { + lv_disp_flush_ready(disp); + }); + + // Dialogs are always shown over the main view + ViewMain::instance(); + MainWindow::instance()->run(); + + auto rndialog = new ProgressDialog(STR_RENAME_LABEL, [=]() {}); + lv_obj_t* bar = findProgressBar(rndialog->getLvObj()); + ASSERT_NE(bar, nullptr); + + modelCellManager.renameLabel( + "Foo", "Bar", [=](const char *name, int percentage) { + if (!lv_obj_is_valid(bar)) { + // 100% again is a no-op, anything else writes to the freed bar + EXPECT_EQ(percentage, 100) + << percentage << "% progress reported after the dialog was" + << " closed and its progress bar freed"; + return; + } + + rndialog->setTitle(std::string(STR_RENAME_LABEL) + " " + name); + rndialog->updateProgress(percentage); + if (percentage >= 100) rndialog->closeDialog(); + }); + + MainWindow::instance()->run(); + EXPECT_EQ(countOf(modelCellManager.getLabels(), "Bar"), 1); + + // Restore simulator LCD callbacks + lcdInit(); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelOntoExistingLabelNeverDuplicatesLabel) +{ + writeFixtureModel("model0001.yml", "One", "Bar"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + writeFixtureModel("model0003.yml", "Three", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.renameLabel("Foo", "Bar", [&](const char*, int) { + EXPECT_EQ(countOf(modelCellManager.getLabels(), "Bar"), 1) + << "label list has duplicate entries during merge"; + }); + + EXPECT_EQ(modelCellManager.getIndexByLabel("Foo"), -1); + EXPECT_EQ(labelsOf("model0003.yml"), (LabelsVector{"Bar"})); +} + +TEST_F(ModelCellManagerFsTest, RenameLabelOntoExistingLabelRejectedIfTooLong) +{ + // 5 x 16 + 13 + 5 separators = 98 chars, merging the 13 char label into a + // 16 char label would need 101 + std::vector many; + for (int i = 0; i < 5; i++) many.push_back(std::string(16, 'A' + i)); + many.push_back(std::string(13, 'F')); + std::string existing(16, 'Z'); + + writeFixtureModel("model0001.yml", "One", existing.c_str()); + writeFixtureModel("model0002.yml", "Two", joinCSV(many).c_str()); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(labelsOf("model0002.yml").size(), 6u); + + modelCellManager.renameLabel(many[5], existing); + + EXPECT_EQ(countOf(modelCellManager.getLabels(), many[5]), 1); + EXPECT_EQ(countOf(modelCellManager.getLabels(), existing), 1); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted(many)); + EXPECT_EQ(labelsOf("model0001.yml"), (LabelsVector{existing})); +} + +// --------------------------------------------------------------------------- +// removeLabel +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, RemoveLabelShiftsIndexesAcrossSaveAndReload) +{ + writeFixtureModel("model0001.yml", "One", "A,C"); + writeFixtureModel("model0002.yml", "Two", "B,D"); + writeFixtureModel("model0003.yml", "Three", "C,D"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(sorted(modelCellManager.getLabels()), sorted({"A", "B", "C", "D"})); + + modelCellManager.removeLabel("B"); + + EXPECT_EQ(sorted(modelCellManager.getLabels()), sorted({"A", "C", "D"})); + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"A", "C"})); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"D"})); + EXPECT_EQ(sorted(labelsOf("model0003.yml")), sorted({"C", "D"})); + EXPECT_STREQ(readHeader("model0002.yml").header.labels, "D"); + + // Persist and reload: nothing may drift after the index shift. + ASSERT_EQ(modelCellManager.save(), (const char*)nullptr); + modelCellManager.clear(); + modelCellManager.load(); + + EXPECT_EQ(sorted(modelCellManager.getLabels()), sorted({"A", "C", "D"})); + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"A", "C"})); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"D"})); + EXPECT_EQ(sorted(labelsOf("model0003.yml")), sorted({"C", "D"})); +} + +TEST_F(ModelCellManagerFsTest, RemoveLastLabelAddsFavorites) +{ + writeFixtureModel("model0001.yml", "One", "Only"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(modelCellManager.getLabels(), (LabelsVector{"Only"})); + + modelCellManager.removeLabel("Only"); + + EXPECT_EQ(modelCellManager.getLabels(), (LabelsVector{STR_FAVORITE_LABEL})); + EXPECT_TRUE(labelsOf("model0001.yml").empty()); +} + +TEST_F(ModelCellManagerFsTest, RemoveLabelUsedByCurrentModelDirtiesModel) +{ + writeFixtureModel("model0001.yml", "One", "Foo,Keep"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + storageDirtyMsk = 0; + + modelCellManager.removeLabel("Foo"); + + EXPECT_EQ(labelsOf("model0001.yml"), (LabelsVector{"Keep"})); + EXPECT_STREQ(g_model.header.labels, "Keep"); + EXPECT_TRUE(storageDirtyMsk & EE_MODEL); + EXPECT_TRUE(storageDirtyMsk & EE_LABELS); +} + +TEST_F(ModelCellManagerFsTest, RemoveLabelReportsProgressToCompletion) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + writeFixtureModel("model0002.yml", "Two", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + int calls = 0, last = -1; + modelCellManager.removeLabel("Foo", [&](const char*, int pct) { + calls++; + last = pct; + }); + EXPECT_GT(calls, 0); + EXPECT_EQ(last, 100); +} + +TEST_F(ModelCellManagerFsTest, RemoveUnknownLabelIsNoop) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + modelCellManager.removeLabel("Nope"); + + EXPECT_EQ(modelCellManager.getLabels(), (LabelsVector{"Foo"})); + EXPECT_EQ(labelsOf("model0001.yml"), (LabelsVector{"Foo"})); +} + +// --------------------------------------------------------------------------- +// moveLabelUp +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, MoveLabelUpKeepsMembershipAndSurvivesReload) +{ + writeFixtureModel("model0001.yml", "One", "A"); + writeFixtureModel("model0002.yml", "Two", "B"); + writeFixtureModel("model0003.yml", "Three", "A,C"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + // Label order depends on directory scan order, so swap whichever two + // labels ended up at positions 0 and 1. + auto order = modelCellManager.getLabels(); + ASSERT_EQ(order.size(), 3u); + + modelCellManager.moveLabelUp(1); + + EXPECT_EQ(modelCellManager.getIndexByLabel(order[0]), 1); + EXPECT_EQ(modelCellManager.getIndexByLabel(order[1]), 0); + EXPECT_EQ(modelCellManager.getIndexByLabel(order[2]), 2); + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"A"})); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"B"})); + EXPECT_EQ(sorted(labelsOf("model0003.yml")), sorted({"A", "C"})); + + LabelsVector expectedOrder = modelCellManager.getLabels(); + ASSERT_EQ(modelCellManager.save(), (const char*)nullptr); + modelCellManager.clear(); + modelCellManager.load(); + + EXPECT_EQ(modelCellManager.getLabels(), expectedOrder); + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"A"})); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"B"})); + EXPECT_EQ(sorted(labelsOf("model0003.yml")), sorted({"A", "C"})); +} + +TEST_F(ModelCellManagerFsTest, MoveLabelUpOutOfRangeIsNoop) +{ + writeFixtureModel("model0001.yml", "One", "A,B"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + LabelsVector before = modelCellManager.getLabels(); + + modelCellManager.moveLabelUp(0); + EXPECT_EQ(modelCellManager.getLabels(), before); + modelCellManager.moveLabelUp(200); + EXPECT_EQ(modelCellManager.getLabels(), before); + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"A", "B"})); +} + +// --------------------------------------------------------------------------- +// Duplicating a model +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, DuplicateCopiesLabelsButNotFilenameOrHash) +{ + writeFixtureModel("model0001.yml", "Alpha", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ModelCell* src = modelCellManager.getModel("model0001.yml"); + ASSERT_NE(src, nullptr); + ASSERT_GT(strlen(src->modelFinfoHash), 0u); + + ModelCell* dup = modelCellManager.addModel("model0002.yml", false, src); + + EXPECT_STREQ(dup->modelFilename, "model0002.yml"); + EXPECT_STRNE(dup->modelFinfoHash, src->modelFinfoHash); + EXPECT_EQ(dup->getLabels(), src->getLabels()); + EXPECT_TRUE(dup->hasLabel("Foo")); + EXPECT_NE(dup, src); +} + +// The real "duplicate" sequence: copy the file, add the cell, make the name +// unique and patch the header of the copy. +TEST_F(ModelCellManagerFsTest, DuplicateWritesUniqueNameToFilePreservingBody) +{ + writeFixtureModel("model0001.yml", "Alpha", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ModelCell* src = modelCellManager.getModel("model0001.yml"); + ASSERT_NE(src, nullptr); + + fs::copy_file(realPath("model0001.yml"), realPath("model0003.yml")); + ModelCell* dup = modelCellManager.addModel("model0003.yml", true, src); + dup->setUniqueName(); + dup->updateModelFile(); + + EXPECT_STREQ(dup->modelName, "Alpha(1)"); + + PartialModel dupHdr = readHeader("model0003.yml"); + EXPECT_STREQ(dupHdr.header.name, "Alpha(1)"); + EXPECT_STREQ(dupHdr.header.labels, "Foo"); + + // Source untouched, and the body of the copy is byte-identical + EXPECT_STREQ(readHeader("model0001.yml").header.name, "Alpha"); + std::string body = bodyOf("model0001.yml"); + ASSERT_FALSE(body.empty()); + EXPECT_EQ(bodyOf("model0003.yml"), body); +} + +TEST_F(ModelCellManagerFsTest, DuplicateOfCurrentModelDoesNotTouchCurrentName) +{ + writeFixtureModel("model0001.yml", "Alpha", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ModelCell* src = modelCellManager.getCurrentModel(); + ASSERT_NE(src, nullptr); + + fs::copy_file(realPath("model0001.yml"), realPath("model0002.yml")); + ModelCell* dup = modelCellManager.addModel("model0002.yml", true, src); + dup->setUniqueName(); + dup->updateModelFile(); + + EXPECT_EQ(modelCellManager.getCurrentModel(), src); + EXPECT_STREQ(src->modelName, "Alpha"); + EXPECT_STREQ(readHeader("model0002.yml").header.name, "Alpha(1)"); +} + +// --------------------------------------------------------------------------- +// labels.yml load / save +// --------------------------------------------------------------------------- + +TEST_F(ModelCellManagerFsTest, NoLabelsAnywhereCreatesFavoritesLabel) +{ + writeFixtureModel("model0001.yml", "One", ""); + writeFixtureModel("model0002.yml", "Two", ""); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + EXPECT_EQ(modelCellManager.getLabels(), (LabelsVector{STR_FAVORITE_LABEL})); + EXPECT_EQ(modelCellManager.getUnlabeledModels().size(), 2u); +} + +TEST_F(ModelCellManagerFsTest, LoadBuildsCellsFromModelFilesWithoutLabelsYml) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + setCurrentFilename("model0002.yml"); + modelCellManager.load(); + + ASSERT_EQ(modelCellManager.size(), 2u); + EXPECT_STREQ(modelCellManager.getModel("model0001.yml")->modelName, "One"); + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"Foo", "Bar"})); + EXPECT_EQ(modelCellManager.getCurrentModel(), + modelCellManager.getModel("model0002.yml")); +} + +TEST_F(ModelCellManagerFsTest, ChangedModelFileReplacesLabelsRatherThanMerging) +{ + writeFixtureModel("model0001.yml", "One", "Foo,Bar"); + writeFixtureModel("model0002.yml", "Two", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(modelCellManager.save(), (const char*)nullptr); + + // Edit a model file behind the radio's back (e.g. Companion). The new + // content is longer so the size/date hash certainly changes. + writeFixtureModel("model0002.yml", "Two", "Bar,Baz"); + + modelCellManager.clear(); + modelCellManager.load(); + + EXPECT_EQ(sorted(labelsOf("model0002.yml")), sorted({"Bar", "Baz"})) + << "stale label from labels.yml survived a model file change"; + EXPECT_EQ(sorted(labelsOf("model0001.yml")), sorted({"Foo", "Bar"})); +} + +TEST_F(ModelCellManagerFsTest, LabelsYmlEntryForMissingModelFileIsIgnored) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + ASSERT_EQ(modelCellManager.save(), (const char*)nullptr); + + { + std::ofstream out(realPath(LABELS_FILENAME), std::ios::app); + out << " model0099.yml:\r\n" + << " hash: \"0000000000000000\"\r\n" + << " name: \"Ghost\"\r\n" + << " labels: \"Foo,Ghostly\"\r\n"; + } + + modelCellManager.clear(); + modelCellManager.load(); + + EXPECT_EQ(modelCellManager.size(), 1u); + EXPECT_EQ(modelCellManager.getModel("model0099.yml"), nullptr); + EXPECT_EQ(labelsOf("model0001.yml"), (LabelsVector{"Foo"})); +} + +TEST_F(ModelCellManagerFsTest, SaveLoadRoundTripKeepsSortOrderAndFilter) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + writeFixtureModel("model0002.yml", "Two", "Bar"); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + int bar = modelCellManager.getIndexByLabel("Bar"); + ASSERT_GE(bar, 0); + modelCellManager.setSortOrder(DATE_DES); + modelCellManager.setFilteredLabels({(uint32_t)bar}); + ASSERT_EQ(modelCellManager.save(), (const char*)nullptr); + + // Change the in-memory state so only a real reload can restore it + modelCellManager.setSortOrder(NAME_DES); + modelCellManager.clearFilter(); + modelCellManager.clear(); + modelCellManager.load(); + + EXPECT_EQ(modelCellManager.sortOrder(), DATE_DES); + EXPECT_TRUE(modelCellManager.isLabelFiltered("Bar")); + EXPECT_FALSE(modelCellManager.isLabelFiltered("Foo")); +} + +TEST_F(ModelCellManagerFsTest, FilteredModelListHonoursLabelsAndUnlabeled) +{ + writeFixtureModel("model0001.yml", "One", "Foo"); + writeFixtureModel("model0002.yml", "Two", "Foo,Bar"); + writeFixtureModel("model0003.yml", "Three", ""); + setCurrentFilename("model0001.yml"); + modelCellManager.load(); + + g_eeGeneral.labelMultiMode = 0; // AND + auto both = modelCellManager.getFilteredModelList({"Foo", "Bar"}); + ASSERT_EQ(both.size(), 1u); + EXPECT_STREQ(both[0]->modelName, "Two"); + + g_eeGeneral.labelMultiMode = 1; // OR + auto either = modelCellManager.getFilteredModelList({"Foo", "Bar"}); + EXPECT_EQ(either.size(), 2u); + + auto none = modelCellManager.getFilteredModelList({STR_UNLABELEDMODEL}); + ASSERT_EQ(none.size(), 1u); + EXPECT_STREQ(none[0]->modelName, "Three"); +} + +TEST_F(ModelCellManagerFsTest, SortModelsByNameAndDate) +{ + ModelCell* b = addCell("model0001.yml", "bravo"); + ModelCell* a = addCell("model0002.yml", "Alpha"); + ModelCell* c = addCell("model0003.yml", "Charlie"); + a->lastOpened = 30; + b->lastOpened = 20; + c->lastOpened = 10; + + modelCellManager.setSortOrder(NAME_ASC); + auto v = modelCellManager.getAllModels(); + EXPECT_EQ(v, (ModelsVector{a, b, c})); // case-insensitive + + modelCellManager.setSortOrder(NAME_DES); + v = modelCellManager.getAllModels(); + EXPECT_EQ(v, (ModelsVector{c, b, a})); + + modelCellManager.setSortOrder(DATE_DES); + v = modelCellManager.getAllModels(); + EXPECT_EQ(v, (ModelsVector{a, b, c})); + + modelCellManager.setSortOrder(DATE_ASC); + v = modelCellManager.getAllModels(); + EXPECT_EQ(v, (ModelsVector{c, b, a})); +} + +#endif // defined(COLORLCD)