diff --git a/gpt4all-chat/qml/AddRemoteModelView.qml b/gpt4all-chat/qml/AddRemoteModelView.qml index 57acff58..07df00c0 100644 --- a/gpt4all-chat/qml/AddRemoteModelView.qml +++ b/gpt4all-chat/qml/AddRemoteModelView.qml @@ -79,25 +79,6 @@ ColumnLayout { })[provider.id.toString()] } } - RemoteModelCard { - width: parent.childWidth - height: parent.childHeight - providerUsesApiKey: false - providerName: qsTr("Ollama (Custom)") - providerImage: "qrc:/gpt4all/icons/antenna_3.svg" - providerDesc: qsTr("Configure a custom Ollama provider.") - } - // TODO(jared): add custom openai back to the list - /* - RemoteModelCard { - width: parent.childWidth - height: parent.childHeight - providerIsCustom: true - providerName: qsTr("Custom") - providerImage: "qrc:/gpt4all/icons/antenna_3.svg" - providerDesc: qsTr("The custom provider option allows users to connect their own OpenAI-compatible AI models or third-party inference services. This is useful for organizations with proprietary models or those leveraging niche AI providers not listed here.") - } - */ } } } diff --git a/gpt4all-chat/qml/RemoteModelCard.qml b/gpt4all-chat/qml/RemoteModelCard.qml index f2cbe0e6..9c853ecb 100644 --- a/gpt4all-chat/qml/RemoteModelCard.qml +++ b/gpt4all-chat/qml/RemoteModelCard.qml @@ -4,17 +4,12 @@ import QtQuick.Layouts Rectangle { id: remoteModelCard - property var provider: null + property var provider // required property alias providerName: providerNameLabel.text property alias providerImage: myimage.source property alias providerDesc: providerDescLabel.text property bool providerUsesApiKey: true - // for internal use - property bool apiKeyRequired: provider === null ? providerUsesApiKey : "apiKey" in provider - property bool apiKeyGood: !apiKeyRequired // (overwritten later if required) - property bool baseUrlGood: provider !== null // (overwritten later if custom) - color: theme.conversationBackground radius: 10 border.width: 1 @@ -78,7 +73,38 @@ Rectangle { spacing: 30 ColumnLayout { - visible: apiKeyRequired + visible: !provider.isBuiltin + + MySettingsLabel { + text: qsTr("Name") + font.bold: true + font.pixelSize: theme.fontSizeLarge + color: theme.settingsTitleTextColor + } + + MyTextField { + id: nameField + property bool initialized: false + property bool ok: true + Layout.fillWidth: true + font.pixelSize: theme.fontSizeLarge + wrapMode: Text.WrapAnywhere + Component.onCompleted: { + text = provider.name; + initialized = true; + } + onTextChanged: { + if (!initialized) return; + ok = provider.setNameQml(text.trim()); + } + placeholderText: qsTr("Provider Name") + Accessible.role: Accessible.EditableText + Accessible.name: placeholderText + } + } + + ColumnLayout { + visible: "apiKey" in provider MySettingsLabel { text: qsTr("API Key") @@ -89,27 +115,32 @@ Rectangle { MyTextField { id: apiKeyField + property bool initialized: false + property bool ok: false Layout.fillWidth: true font.pixelSize: theme.fontSizeLarge wrapMode: Text.WrapAnywhere echoMode: TextField.Password - function showError() { - messageToast.show(qsTr("ERROR: $API_KEY is empty.")); - apiKeyField.placeholderTextColor = theme.textErrorColor; + Component.onCompleted: { + if (parent.visible) { + text = provider.apiKey; + ok = text.trim() != ""; + } else + ok = true; + initialized = true; } - Component.onCompleted: { if (parent.visible && provider !== null) { text = provider.apiKey; } } onTextChanged: { - apiKeyField.placeholderTextColor = theme.mutedTextColor; - if (provider !== null) { apiKeyGood = provider.setApiKeyQml(text) && text !== ""; } + if (!initialized) return; + ok = provider.setApiKeyQml(text.trim()) && text.trim() !== ""; } - placeholderText: qsTr("enter $API_KEY") + placeholderText: qsTr("Provider API Key") Accessible.role: Accessible.EditableText Accessible.name: placeholderText } } ColumnLayout { - visible: provider === null + visible: !provider.isBuiltin MySettingsLabel { text: qsTr("Base Url") font.bold: true @@ -118,18 +149,20 @@ Rectangle { } MyTextField { id: baseUrlField + property bool initialized: false + property bool ok: true Layout.fillWidth: true font.pixelSize: theme.fontSizeLarge wrapMode: Text.WrapAnywhere - function showError() { - messageToast.show(qsTr("ERROR: $BASE_URL is empty.")); - baseUrlField.placeholderTextColor = theme.textErrorColor; + Component.onCompleted: { + text = provider.baseUrl; + initialized = true; } onTextChanged: { - baseUrlField.placeholderTextColor = theme.mutedTextColor; - baseUrlGood = text.trim() !== ""; + if (!initialized) return; + ok = provider.setBaseUrlQml(text.trim()) && text.trim() !== ""; } - placeholderText: qsTr("enter $BASE_URL") + placeholderText: qsTr("Provider Base URL") Accessible.role: Accessible.EditableText Accessible.name: placeholderText } @@ -152,25 +185,15 @@ Rectangle { Layout.fillWidth: true id: myModelList currentIndex: -1 - property bool ready: baseUrlGood && apiKeyGood + property bool ready: nameField.ok && baseUrlField.ok && apiKeyField.ok onReadyChanged: { - if (!ready) { return; } - let providerRef = null; // owns the new provider - let provider = remoteModelCard.provider; - if (provider === null) { - // TODO: custom OpenAI - providerRef = QmlFunctions.newCustomOllamaProvider("foo", baseUrlField.text.trim()); - if (providerRef !== null) - provider = providerRef.get(); - } - if (provider !== null) { - provider.listModelsQml().then(modelList => { - if (modelList !== null) { - model = modelList; - currentIndex = -1; - } - }); - } + if (!ready) return; + provider.listModelsQml().then(modelList => { + if (modelList !== null) { + model = modelList; + currentIndex = -1; + } + }); } } } @@ -183,7 +206,6 @@ Rectangle { font.pixelSize: theme.fontSizeLarge property string apiKeyText: apiKeyField.text.trim() - property string baseUrlText: provider === null ? baseUrlField.text.trim() : provider.baseUrl property string modelNameText: myModelList.currentText.trim() enabled: baseUrlGood && apiKeyGood && modelNameText !== "" diff --git a/gpt4all-chat/src/llmodel_ollama.h b/gpt4all-chat/src/llmodel_ollama.h index 0cd3270e..a3b128e2 100644 --- a/gpt4all-chat/src/llmodel_ollama.h +++ b/gpt4all-chat/src/llmodel_ollama.h @@ -46,7 +46,8 @@ protected: class OllamaProvider : public QObject, public virtual ModelProvider { Q_OBJECT - Q_PROPERTY(QUuid id READ id CONSTANT) + Q_PROPERTY(QUuid id READ id CONSTANT) + Q_PROPERTY(bool isBuiltin READ isBuiltin CONSTANT) protected: explicit OllamaProvider(); diff --git a/gpt4all-chat/src/llmodel_openai.cpp b/gpt4all-chat/src/llmodel_openai.cpp index bfd2642b..7065cd15 100644 --- a/gpt4all-chat/src/llmodel_openai.cpp +++ b/gpt4all-chat/src/llmodel_openai.cpp @@ -98,14 +98,6 @@ OpenaiProvider::OpenaiProvider(QString apiKey) OpenaiProvider::~OpenaiProvider() noexcept = default; -Q_INVOKABLE bool OpenaiProvider::setApiKeyQml(QString value) -{ - auto res = setApiKey(std::move(value)); - if (!res) - qWarning().noquote() << "setApiKey failed:" << res.error().errorString(); - return bool(res); -} - auto OpenaiProvider::supportedGenerationParams() const -> QSet { using enum GenerationParam; diff --git a/gpt4all-chat/src/llmodel_openai.h b/gpt4all-chat/src/llmodel_openai.h index 4d92c627..26753ebd 100644 --- a/gpt4all-chat/src/llmodel_openai.h +++ b/gpt4all-chat/src/llmodel_openai.h @@ -50,8 +50,9 @@ protected: class OpenaiProvider : public QObject, public virtual ModelProvider { Q_OBJECT - Q_PROPERTY(QUuid id READ id CONSTANT ) - Q_PROPERTY(QString apiKey READ apiKey NOTIFY apiKeyChanged) + Q_PROPERTY(QUuid id READ id CONSTANT ) + Q_PROPERTY(QString apiKey READ apiKey NOTIFY apiKeyChanged) + Q_PROPERTY(bool isBuiltin READ isBuiltin CONSTANT ) protected: explicit OpenaiProvider(); @@ -66,7 +67,8 @@ public: [[nodiscard]] const QString &apiKey() const { return m_apiKey; } [[nodiscard]] virtual DataStoreResult<> setApiKey(QString value) = 0; - Q_INVOKABLE bool setApiKeyQml(QString value); + Q_INVOKABLE bool setApiKeyQml(QString value) + { return wrapQmlFunc(this, &OpenaiProvider::setApiKey, u"setApiKey", std::move(value)); } auto supportedGenerationParams() const -> QSet override; auto makeGenerationParams(const QMap &values) const -> OpenaiGenerationParams * override; @@ -107,7 +109,7 @@ public: std::unordered_set modelWhitelist); [[nodiscard]] DataStoreResult<> setApiKey(QString value) override - { return setMemberProp(&OpenaiProviderBuiltin::m_apiKey, "apiKey", std::move(value), /*createName*/ m_name); } + { return setMemberProp(&OpenaiProviderBuiltin::m_apiKey, "apiKey", std::move(value), /*create*/ true); } // override for model whitelist auto listModels() -> QCoro::Task> override; diff --git a/gpt4all-chat/src/llmodel_provider.cpp b/gpt4all-chat/src/llmodel_provider.cpp index 45cac7c0..aca46f44 100644 --- a/gpt4all-chat/src/llmodel_provider.cpp +++ b/gpt4all-chat/src/llmodel_provider.cpp @@ -82,6 +82,13 @@ ModelProviderMutable::~ModelProviderMutable() noexcept res.error().raise(); // should not happen - will terminate program } +DataStoreResult<> ModelProviderCustom::setName(QString value) +{ + if (value.isEmpty()) + return std::unexpected(u"name cannot be empty"_s); + return setMemberProp(&ModelProviderCustom::m_name, "name", std::move(value)); +} + auto ModelProviderCustom::persist() -> DataStoreResult<> { if (auto res = m_store->create(asData()); !res) @@ -112,10 +119,10 @@ void ProviderRegistry::load() auto registerListener = [this](ModelProvider *provider) { // listen for any change in the provider so we can tell the model about it if (auto *mut = dynamic_cast(provider)) - connect(mut->asQObject(), "apiKeyChanged", this, "onProviderChanged"); + connect(mut->asQObject(), SIGNAL(apiKeyChanged (const QString &)), this, SLOT(onProviderChanged())); if (auto *cust = dynamic_cast(provider)) { - connect(cust->asQObject(), "nameChanged", this, "onProviderChanged"); - connect(cust->asQObject(), "baseUrlChanged", this, "onProviderChanged"); + connect(cust->asQObject(), SIGNAL(nameChanged (const QString &)), this, SLOT(onProviderChanged())); + connect(cust->asQObject(), SIGNAL(baseUrlChanged(const QUrl &)), this, SLOT(onProviderChanged())); } }; for (auto &p : s_builtinProviders) { // (not all builtin providers are stored) @@ -273,8 +280,8 @@ void ProviderList::onAboutToBeCleared() bool ProviderListSort::lessThan(const QModelIndex &left, const QModelIndex &right) const { - auto *leftData = sourceModel()->data(left ).value(); - auto *rightData = sourceModel()->data(right).value(); + auto *leftData = dynamic_cast(sourceModel()->data(left ).value()); + auto *rightData = dynamic_cast(sourceModel()->data(right).value()); if (leftData && rightData) { if (leftData->isBuiltin() != rightData->isBuiltin()) return leftData->isBuiltin() > rightData->isBuiltin(); // builtins first diff --git a/gpt4all-chat/src/llmodel_provider.h b/gpt4all-chat/src/llmodel_provider.h index f811d254..58aaf673 100644 --- a/gpt4all-chat/src/llmodel_provider.h +++ b/gpt4all-chat/src/llmodel_provider.h @@ -61,11 +61,15 @@ concept is_expected = is_expected_impl>::value; /// Drop the type and error information from a QCoro::Task> so it can be used by QML. template requires (!detail::is_expected::value_type>) -QCoro::QmlTask wrapQmlTask(std::shared_ptr c, F f, QString prefix, Args &&...args); +QCoro::QmlTask wrapQmlTask(C *obj, F f, QString prefix, Args &&...args); template requires detail::is_expected::value_type> -QCoro::QmlTask wrapQmlTask(std::shared_ptr c, F f, QString prefix, Args &&...args); +QCoro::QmlTask wrapQmlTask(C *obj, F f, QString prefix, Args &&...args); + +/// Drop the error information from a DataOrRespErr so it can be used by QML. +template +bool wrapQmlFunc(C *obj, F &&f, QStringView prefix, Args &&...args); enum class GenerationParam { NPredict, @@ -182,7 +186,7 @@ protected: template [[nodiscard]] DataStoreResult<> setMemberProp(this S &self, T C::* member, std::string_view name, T value, - std::optional createName = {}); + bool create = false); [[nodiscard]] virtual bool persisted() const { return true; } @@ -198,11 +202,16 @@ public: bool isBuiltin() const final { return false; } // setters - [[nodiscard]] DataStoreResult<> setName (QString value) - { return setMemberProp(&ModelProviderCustom::m_name, "name", std::move(value)); } + [[nodiscard]] DataStoreResult<> setName (QString value); [[nodiscard]] DataStoreResult<> setBaseUrl(QUrl value) { return setMemberProp(&ModelProviderCustom::m_baseUrl, "baseUrl", std::move(value)); } + // QML setters + Q_INVOKABLE bool setNameQml (QString value) + { return wrapQmlFunc(this, &ModelProviderCustom::setName, u"setName", std::move(value)); } + Q_INVOKABLE bool setBaseUrlQml(QString value) + { return wrapQmlFunc(this, &ModelProviderCustom::setBaseUrl, u"setBaseUrl", std::move(value)); } + [[nodiscard]] auto persist() -> DataStoreResult<>; protected: @@ -264,7 +273,7 @@ private: ProviderStore m_customStore; ProviderStore m_builtinStore; std::unordered_map> m_providers; - std::vector m_providerList; // TODO: implement + std::vector m_providerList; }; class ProviderList : public QAbstractListModel { @@ -296,7 +305,7 @@ class ProviderListSort : public QSortFilterProxyModel { QML_ELEMENT private: - explicit ProviderListSort() { setSourceModel(&m_model); } + explicit ProviderListSort() { setSourceModel(&m_model); sort(0); } public: static ProviderListSort *create(QQmlEngine *, QJSEngine *) { return new ProviderListSort(); } diff --git a/gpt4all-chat/src/llmodel_provider.inl b/gpt4all-chat/src/llmodel_provider.inl index 837763a3..1b88ad73 100644 --- a/gpt4all-chat/src/llmodel_provider.inl +++ b/gpt4all-chat/src/llmodel_provider.inl @@ -38,6 +38,14 @@ QCoro::QmlTask wrapQmlTask(C *obj, F f, QString prefix, Args &&...args) }(std::move(ptr), std::move(f), std::move(prefix), std::forward(args)...); } +template +bool wrapQmlFunc(C *obj, F &&f, QStringView prefix, Args &&...args) +{ + auto res = std::invoke(std::forward(f), obj, std::forward(args)...); + if (!res) { qWarning().noquote() << prefix << "failed:" << res.error().errorString(); } + return bool(res); +} + template void GenerationParams::tryParseValue(this S &self, QMap &values, GenerationParam key, T C::* dest) @@ -48,15 +56,15 @@ void GenerationParams::tryParseValue(this S &self, QMap auto ModelProviderMutable::setMemberProp(this S &self, T C::* member, std::string_view name, T value, - std::optional createName) -> DataStoreResult<> + bool create) -> DataStoreResult<> { - auto &mpc = static_cast(self); + auto &mpm = static_cast(self); auto &cur = self.*member; if (cur != value) { cur = std::move(value); - if (mpc.persisted()) { - auto data = mpc.asData(); - if (auto res = mpc.m_store->setData(std::move(data), createName); !res) + if (mpm.persisted()) { + auto data = mpm.asData(); + if (auto res = mpm.m_store->setData(std::move(data), mpm.name(), create); !res) return res; } QMetaObject::invokeMethod(self.asQObject(), fmt::format("{}Changed", name).c_str(), cur); diff --git a/gpt4all-chat/src/store_base.cpp b/gpt4all-chat/src/store_base.cpp index d8c1b9d1..d0f01dd3 100644 --- a/gpt4all-chat/src/store_base.cpp +++ b/gpt4all-chat/src/store_base.cpp @@ -82,7 +82,7 @@ auto DataStoreBase::reload() -> DataStoreResult<> } for (auto &entry : it) { - if (!entry.is_regular_file()) + if (!entry.is_regular_file() || entry.path().extension() != ".json") continue; // skip directories and such file.setFileName(entry.path()); if (!file.open(QFile::ReadOnly)) { @@ -93,7 +93,7 @@ auto DataStoreBase::reload() -> DataStoreResult<> if (!jv) { (qWarning().nospace() << "skipping " << file.fileName() << " because of read error: ").noquote() << jv.error().errorString(); - } else if (auto [unique, uuid] = cacheInsert(*jv); !unique) + } else if (auto [unique, uuid] = cacheInsert(entry.path().stem(), *jv); !unique) qWarning() << "skipping duplicate data store entry:" << uuid; file.close(); } @@ -109,12 +109,23 @@ auto DataStoreBase::setPath(fs::path path) -> DataStoreResult<> return {}; } -auto DataStoreBase::getFilePath(const QString &name) -> fs::path -{ return m_path / fmt::format("{}.json", QLatin1StringView(normalizeName(name))); } - -auto DataStoreBase::openNew(const QString &name) -> DataStoreResult> +QByteArray DataStoreBase::normalizeName(const QString &name) { - auto path = getFilePath(name); + auto lower = name.toLower(); + auto norm = QUrl::toPercentEncoding(lower, /*exclude*/ " !#$%&'()+,;=@[]^`{}"_ba, /*include*/ "~"_ba); + + // leading dot indicates a hidden file on Unix + if (norm.startsWith('.')) + norm = "%2E"_ba.append(QByteArrayView(norm).slice(1)); + return norm; +} + +auto DataStoreBase::getFilePath(const QByteArray &normName) -> fs::path +{ return m_path / fmt::format("{}.json", QLatin1StringView(normName)); } + +auto DataStoreBase::openNew(const QByteArray &normName) -> DataStoreResult> +{ + auto path = getFilePath(normName); auto file = std::make_unique(path); if (file->exists()) return std::unexpected(sys::system_error(std::make_error_code(std::errc::file_exists), path.string())); @@ -123,9 +134,9 @@ auto DataStoreBase::openNew(const QString &name) -> DataStoreResult DataStoreResult> +auto DataStoreBase::openExisting(const QByteArray &normName, bool allowCreate) -> DataStoreResult> { - auto path = getFilePath(name); + auto path = getFilePath(normName); if (!allowCreate && !QFile::exists(path)) return std::unexpected(sys::system_error( std::make_error_code(std::errc::no_such_file_or_directory), path.string() @@ -213,16 +224,5 @@ auto DataStoreBase::write(const json::value &value, QFileDevice &file) -> DataSt return {}; } -QByteArray DataStoreBase::normalizeName(const QString &name) -{ - auto lower = name.toLower(); - auto norm = QUrl::toPercentEncoding(lower, /*exclude*/ " !#$%&'()+,;=@[]^`{}"_ba, /*include*/ "~"_ba); - - // "." and ".." are special filenames - return norm == "."_ba ? "%2E"_ba : - norm == ".."_ba ? "%2E%2E"_ba : - norm; -} - } // namespace gpt4all::ui diff --git a/gpt4all-chat/src/store_base.h b/gpt4all-chat/src/store_base.h index ae64c86c..61b202c4 100644 --- a/gpt4all-chat/src/store_base.h +++ b/gpt4all-chat/src/store_base.h @@ -70,21 +70,20 @@ public: protected: auto reload() -> DataStoreResult<>; virtual auto clear() -> DataStoreResult<> = 0; - struct CacheInsertResult { bool unique; QUuid uuid; }; - virtual CacheInsertResult cacheInsert(const boost::json::value &jv) = 0; + struct CacheInsertResult { bool unique; QUuid id; }; + virtual CacheInsertResult cacheInsert(const std::filesystem::path &stem, const boost::json::value &jv) = 0; // helpers - auto getFilePath(const QString &name) -> std::filesystem::path; - auto openNew(const QString &name) -> DataStoreResult>; - auto openExisting(const QString &name, bool allowCreate = false) -> DataStoreResult>; + static QByteArray normalizeName(const QString &name); + auto getFilePath(const QByteArray &normName) -> std::filesystem::path; + auto openNew(const QByteArray &normName) -> DataStoreResult>; + auto openExisting(const QByteArray &normName, bool allowCreate = false) -> DataStoreResult>; static auto read(QFileDevice &file, boost::json::stream_parser &parser) -> DataStoreResult; auto write(const boost::json::value &value, QFileDevice &file) -> DataStoreResult<>; private: static constexpr uint JSON_BUFSIZ = 16384; // default QFILE_WRITEBUFFER_SIZE - static QByteArray normalizeName(const QString &name); - protected: std::filesystem::path m_path; @@ -98,7 +97,7 @@ public: explicit DataStore(std::filesystem::path path); auto list() { return m_entries | std::views::transform([](auto &e) { return e.second; }); } - auto setData(T data, std::optional createName = {}) -> DataStoreResult<>; + auto setData(T data, const QString &name, bool create = false) -> DataStoreResult<>; auto remove(const QUuid &id) -> DataStoreResult<>; auto acquire(QUuid id) -> DataStoreResult>; @@ -112,12 +111,13 @@ public: protected: auto createImpl(T data, const QString &name) -> DataStoreResult<>; auto clear() -> DataStoreResult<> final; - CacheInsertResult cacheInsert(const boost::json::value &jv) override; + CacheInsertResult cacheInsert(const std::filesystem::path &stem, const boost::json::value &jv) override; private: - std::unordered_map m_entries; - std::unordered_set m_acquired; - std::unordered_map m_names; + std::unordered_map m_entries; + std::unordered_map m_normNames; + std::unordered_map m_normNameToId; + std::unordered_set m_acquired; }; diff --git a/gpt4all-chat/src/store_base.inl b/gpt4all-chat/src/store_base.inl index acb846c1..bb888780 100644 --- a/gpt4all-chat/src/store_base.inl +++ b/gpt4all-chat/src/store_base.inl @@ -3,13 +3,13 @@ #include // IWYU pragma: keep #include // IWYU pragma: keep +#include #include #include +#include -#include -#include - -namespace views = std::views; +#include +#include namespace gpt4all::ui { @@ -26,35 +26,74 @@ DataStore::DataStore(std::filesystem::path path) template auto DataStore::createImpl(T data, const QString &name) -> DataStoreResult<> { + // acquire id + typename decltype(m_entries)::iterator entry; + bool unique; + std::tie(entry, unique) = m_entries.emplace(data.id, std::move(data)); + if (!unique) + return std::unexpected(QStringLiteral("id not unique: %1").arg(data.id.toString())); + + auto *pdata = &entry->second; + auto normName = normalizeName(name); + auto [nameIt, unique2] = m_normNames.emplace(pdata->id, normName); + Q_UNUSED(unique2) + Q_ASSERT(unique2); + + // acquire name + typename decltype(m_normNameToId)::iterator n2idIt; + std::tie(n2idIt, unique) = m_normNameToId.emplace(std::move(normName), pdata->id); + if (!unique) { + m_entries.erase(entry); + m_normNames.erase(nameIt); + return std::unexpected(QStringLiteral("name not unique: %1").arg(QLatin1StringView(normName))); + } + // acquire path - auto file = openNew(name); - if (!file) + auto file = openNew(normName); + if (!file) { + m_entries.erase(entry); + m_normNames.erase(nameIt); + m_normNameToId.erase(n2idIt); return std::unexpected(file.error()); + } // serialize - if (auto res = write(boost::json::value_from(data), **file); !res) + if (auto res = write(boost::json::value_from(*pdata), **file); !res) { + m_entries.erase(entry); + m_normNames.erase(nameIt); + m_normNameToId.erase(n2idIt); + if (!(*file)->remove()) + (qWarning().nospace() << "failed to remove " << (*file)->fileName()).noquote() << ": " << (*file)->errorString(); return std::unexpected(res.error()); - - // insert - auto [it, unique] = m_entries.emplace(data.id, std::move(data)); - Q_ASSERT(unique); + } return {}; } template -auto DataStore::setData(T data, std::optional createName) -> DataStoreResult<> +auto DataStore::setData(T data, const QString &name, bool create) -> DataStoreResult<> { - const QString *openName; - auto name_it = m_names.find(data.id); - if (name_it != m_names.end()) { - openName = &name_it->second; - } else if (createName) { - openName = &*createName; + // acquire name + auto normName = normalizeName(name); + auto n2idIt = m_normNameToId.find(normName); + if (n2idIt != m_normNameToId.end() && n2idIt->second != data.id) + return std::unexpected(QStringLiteral("name is not unique: %1").arg(QLatin1StringView(normName))); + + // determine filename to open + bool isNew = false; + QByteArray *openName; + auto nameIt = m_normNames.find(data.id); + if (nameIt != m_normNames.end()) { + openName = &nameIt->second; + } else if (create) { + isNew = true; + openName = &normName; } else return std::unexpected(QStringLiteral("id not found: %1").arg(data.id.toString())); + bool isRename = !isNew && n2idIt == m_normNameToId.end(); + // acquire path - auto file = openExisting(*openName, !!createName); + auto file = openExisting(*openName, create); if (!file) return std::unexpected(file.error()); @@ -64,21 +103,33 @@ auto DataStore::setData(T data, std::optional createName) -> DataSto if (!(*file)->commit()) return std::unexpected(file->get()); - // update - m_entries[data.id] = std::move(data); - - // rename if necessary - if (name_it == m_names.end()) { - m_names.emplace(data.id, std::move(*createName)); - } else if (*createName != name_it->second) { - std::error_code ec; - auto newPath = getFilePath(*createName); - std::filesystem::rename(getFilePath(name_it->second), newPath, ec); - if (ec) - return std::unexpected(ec); - m_names.at(data.id) = std::move(*createName); + // update cache + auto id = data.id; + if (isNew) { + [[maybe_unused]] bool unique; + std::tie(std::ignore, unique) = m_entries .emplace(data.id, std::move(data)); + Q_ASSERT(unique); + std::tie(std::ignore, unique) = m_normNames.emplace(data.id, normName ); + Q_ASSERT(unique); + } else { + m_entries.at(data.id) = std::move(data); + nameIt->second = normName; } + // update name + if (isRename) { + [[maybe_unused]] auto nRemoved = m_normNameToId.erase(*openName); // remove old name + Q_ASSERT(nRemoved); + + std::error_code ec; + std::filesystem::rename(getFilePath(*openName), getFilePath(normName), ec); + if (ec) + return std::unexpected(ec); // FIXME(jared): attempt rollback? the state is now inconsistent. + } + if (isNew || isRename) { + auto [_, unique] = m_normNameToId.emplace(*openName, id); + Q_ASSERT(unique); + } return {}; } @@ -86,20 +137,24 @@ template auto DataStore::remove(const QUuid &id) -> DataStoreResult<> { // acquire UUID - auto it = m_entries.find(id); - if (it == m_entries.end()) + auto nameIt = m_normNames.find(id); + if (nameIt == m_normNames.end()) return std::unexpected(QStringLiteral("id not found: %1").arg(id.toString())); - auto &[_, data] = *it; - // remove the path - auto path = getFilePath(data.name); + auto path = getFilePath(nameIt->second); QFile file(path); if (!file.remove()) throw std::unexpected(&file); // update cache - m_entries.erase(it); + auto normName = std::move(nameIt->second); + m_normNames.erase(nameIt); + [[maybe_unused]] size_t nRemoved; + nRemoved = m_entries.erase(id); + Q_ASSERT(nRemoved); + nRemoved = m_normNameToId.erase(normName); + Q_ASSERT(nRemoved); return {}; } @@ -126,16 +181,27 @@ auto DataStore::clear() -> DataStoreResult<> if (!m_acquired.empty()) return std::unexpected(QStringLiteral("cannot clear data store with living references")); m_entries.clear(); + m_normNameToId.clear(); return {}; } template -auto DataStore::cacheInsert(const boost::json::value &jv) -> CacheInsertResult +auto DataStore::cacheInsert(const std::filesystem::path &stem, const boost::json::value &jv) -> CacheInsertResult { auto data = boost::json::value_to(jv); auto id = data.id; - auto [_, ok] = m_entries.emplace(id, std::move(data)); - return { ok, std::move(id) }; + auto [entryIt, unique] = m_entries.emplace(id, std::move(data)); + if (!unique) + return { .unique = false, .id = std::move(id) }; + auto normName = toQString(stem).toUtf8(); // FIXME(jared): better not to trust the filename + std::tie(std::ignore, unique) = m_normNameToId.emplace(normName, id); + if (!unique) { + m_entries.erase(entryIt); + return { .unique = false, .id = std::move(id) }; + } + std::tie(std::ignore, unique) = m_normNames.emplace(id, std::move(normName)); + Q_ASSERT(unique); + return { .unique = true, .id = std::move(id) }; }