Skip to content

Commit ecad493

Browse files
committed
fix(core): merge user config over defaults so an absent key keeps its C++ default
ConfigMgr2::init() read vnotex.json raw and handed it straight to MainConfig::fromJson(). The IConfig read helpers are not presence-aware, so any key the file did not carry - one added by a newer VNote, or lost to a truncated file - was read back as false/0/"" and then persisted. The docs claimed the opposite. The user's document is now applied as an RFC 7386 merge patch on top of the default-constructed MainConfig's toJson(), from a single read, so every key is present by the time fromJson() runs. This retires the bug class instead of patching one key: the presence-aware readBool overload added for autoFoldPreviewedBlocks is reverted. Three deliberate exceptions: - widget.newNoteDefaultTemplates is owned wholesale by the user (a present but empty object means "none"), so it is restored from the raw snapshot after the merge. - A config that exists but cannot be read - unparseable, or valid JSON that is not an object - now suppresses every main-config write for the session instead of yielding {} and letting defaults overwrite it. - session.json stays unmerged: SessionConfig uses isUndefinedKey() to tell absent from present-and-false. Gate: a generic test drops each leaf key of the defaults document in turn, reloads through a real ConfigMgr2::init(), and asserts the default survives.
1 parent 2320364 commit ecad493

9 files changed

Lines changed: 520 additions & 63 deletions

File tree

‎src/core/AGENTS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ QString templatePath = configMgr->getFileFromConfigFolder("web/markdown-viewer-t
114114
#### Why ConfigMgr2 Exists
115115

116116
`ConfigMgr2` wraps `ConfigCoreService` and adds:
117-
1. **Default merging** — `ConfigMgr2::init()` loads the `vnotex.json` config file via `ConfigCoreService::getConfigByName()`, then calls `MainConfig::fromJson()`. The `MainConfig` constructor first calls `initDefaults()` on all child configs, then `fromJson()` overlays only the keys present in the JSON. Missing keys keep their C++ defaults.
117+
1. **Default merging** — `ConfigMgr2::init()` reads `vnotex.json` ONCE (`ConfigCoreService::getConfigByName`, with the `bool *p_ok` out-param) and applies it as an RFC 7386 merge patch on top of the default-constructed `MainConfig`'s `toJson()` (objects merge per key, a JSON `null` deletes the key, arrays and scalars replace wholesale). The JSON handed to `MainConfig::fromJson()` therefore always carries **every** key, which is what makes a key that a user's older `vnotex.json` never had — or one lost to a truncated file — keep its C++ default. The read helpers in `IConfig` (`readBool`, `readInt`, `readReal`, `readString`) are NOT presence-aware: absent → `false`/`0`/`""`. Their correctness depends entirely on this merge, which is why a throwaway `MainConfig` built from raw `getConfigByName()` JSON (the anti-pattern above) wipes defaults. Three deliberate exceptions:
118+
- **Objects the user owns wholesale** (`kUserOwnedObjects` in `configmgr2.cpp`, currently `widget.newNoteDefaultTemplates`) are restored from the raw document after the merge: for them a present-but-empty object means "none", and per-key merging would resurrect the bundled entries.
119+
- **A config that exists but cannot be read** (unparseable, or valid JSON that is not an object) sets `m_mainConfigReadFailed`, which suppresses every main-config write for the session so defaults can never overwrite it. An absent or empty file is NOT a failure.
120+
- **`session.json` is not merged**: `SessionConfig` uses `isUndefinedKey()` to distinguish "absent" from "present and false".
121+
122+
Regression gates: `testAbsentKeyKeepsTheCppDefaultForEveryField`, `testAnExplicitlyEmptiedUserOwnedMapIsNotResurrected`, `testAnUnreadableConfigIsNeverOverwritten` in `tests/core/test_configmgr2.cpp`.
118123
2. **Debounced persistence** — Changes to config objects auto-save via 500ms debounced timers.
119124
3. **Path resolution** — `getFileFromConfigFolder()` resolves relative paths against the app data directory.
120125
4. **Version upgrade** — `upgradeMainConfigOnVersionChange()` runs the config migration (`doVersionSpecificOverride` then stamping `c_version`) when the persisted version differs.

‎src/core/configmgr2.cpp‎

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,26 +72,96 @@ ConfigMgr2::~ConfigMgr2() {
7272
}
7373
}
7474

75+
// Sections/keys whose object value is owned WHOLESALE by the user, i.e. where a present but
76+
// empty object is a deliberate "none", not "unset". merge_patch merges objects per key, so
77+
// without this the bundled default entries would come back and the user could never turn them
78+
// all off (see WidgetConfig::fromJson for the newNoteDefaultTemplates contract).
79+
struct UserOwnedObject {
80+
const char *m_section;
81+
const char *m_key;
82+
};
83+
84+
constexpr UserOwnedObject kUserOwnedObjects[] = {{"widget", "newNoteDefaultTemplates"}};
85+
86+
void ConfigMgr2::restoreUserOwnedObjects(QJsonObject &p_merged, const QJsonObject &p_raw) {
87+
for (const auto &entry : kUserOwnedObjects) {
88+
const QString section = QLatin1String(entry.m_section);
89+
const QString key = QLatin1String(entry.m_key);
90+
91+
const auto rawSection = p_raw.value(section).toObject();
92+
if (!rawSection.contains(key)) {
93+
continue;
94+
}
95+
96+
auto mergedSection = p_merged.value(section).toObject();
97+
mergedSection[key] = rawSection.value(key);
98+
p_merged[section] = mergedSection;
99+
}
100+
}
101+
102+
// RFC 7386 JSON Merge Patch: @p_patch (the user's document) applied on top of @p_target (the
103+
// defaults document). Objects merge per key, a null DELETES the key, and everything else -
104+
// including arrays - replaces wholesale.
105+
//
106+
// Applied Qt-side, on the ONE raw document read below, rather than through
107+
// ConfigCoreService::getConfigByNameWithDefaults(): that variant re-reads the file, so the
108+
// merged result and the raw snapshot used by restoreUserOwnedObjects() could come from two
109+
// different versions of it.
110+
static QJsonValue applyMergePatch(const QJsonValue &p_target, const QJsonValue &p_patch) {
111+
if (!p_patch.isObject()) {
112+
return p_patch;
113+
}
114+
115+
QJsonObject result = p_target.isObject() ? p_target.toObject() : QJsonObject();
116+
const auto patchObj = p_patch.toObject();
117+
for (auto it = patchObj.begin(); it != patchObj.end(); ++it) {
118+
if (it.value().isNull()) {
119+
result.remove(it.key());
120+
} else {
121+
result[it.key()] = applyMergePatch(result.value(it.key()), it.value());
122+
}
123+
}
124+
return result;
125+
}
126+
75127
void ConfigMgr2::init() {
76128
qCDebug(lcConfig) << "ConfigMgr2 initializing with paths:"
77129
<< "app=" << m_appDataPath << "user=" << m_localDataPath;
78130

79-
// Load and initialize main config
131+
// Load and initialize main config.
132+
//
133+
// The user's document is MERGED on top of the defaults: m_mainConfig is default-constructed
134+
// (every child config has already run its initDefaults()), so its toJson() is exactly the
135+
// defaults document. A key the file does not carry - a key added by a newer VNote, or one
136+
// lost to a truncated/hand-edited file - therefore keeps its C++ default instead of being
137+
// read back as false/0/"". Session config deliberately does NOT go through this path; see
138+
// below.
80139
{
81-
auto mainConfigJson =
82-
m_configService->getConfigByName(DataLocation::App, kMainConfigFileBaseName);
83-
m_versionChanged = MainConfig::peekVersion(mainConfigJson) != c_version.toString();
84-
85-
if (mainConfigJson.isEmpty()) {
86-
// Fresh start: no config file on disk. Keep the default-constructed config
87-
// objects as-is (their C++ initDefaults() provide correct defaults).
88-
qInfo() << "Fresh start detected, using default-constructed config";
140+
// ONE read, used both as the merge patch and as the presence oracle for the user-owned
141+
// objects restored afterwards.
142+
bool readOk = true;
143+
const auto rawJson =
144+
m_configService->getConfigByName(DataLocation::App, kMainConfigFileBaseName, &readOk);
145+
146+
if (!readOk) {
147+
// The file exists but could not be read or parsed. Keep the in-memory defaults and
148+
// refuse to write, so a transient read failure never overwrites the user's settings
149+
// with defaults.
150+
m_mainConfigReadFailed = true;
151+
qWarning() << "Failed to read main config; running on defaults and suppressing writes";
89152
} else {
153+
auto mainConfigJson = applyMergePatch(m_mainConfig->toJson(), rawJson).toObject();
154+
restoreUserOwnedObjects(mainConfigJson, rawJson);
155+
m_versionChanged = MainConfig::peekVersion(mainConfigJson) != c_version.toString();
90156
m_mainConfig->fromJson(mainConfigJson);
91157
}
92158
}
93159

94-
// Load and initialize session config
160+
// Load and initialize session config.
161+
//
162+
// NOT merged on purpose: SessionConfig branches on isUndefinedKey() for systemTitleBar and
163+
// minimizeToSystemTray, i.e. it distinguishes "absent" from "present and false". Merging
164+
// defaults in would make absent impossible and silently change that behavior.
95165
{
96166
auto sessionConfigJson =
97167
m_configService->getConfigByName(DataLocation::Local, kSessionFileBaseName);
@@ -278,6 +348,14 @@ void ConfigMgr2::doWriteMainConfig() {
278348
return;
279349
}
280350

351+
if (m_mainConfigReadFailed) {
352+
// init() could not read the existing config, so what we hold is defaults plus whatever
353+
// this session changed. Writing that out would destroy the user's settings.
354+
qWarning() << "Refusing to write main config: it could not be read at startup";
355+
m_pendingMainConfig = QJsonObject();
356+
return;
357+
}
358+
281359
qDebug() << "Writing main config";
282360
Error err = m_configService->updateConfigByName(DataLocation::App, kMainConfigFileBaseName,
283361
m_pendingMainConfig);

‎src/core/configmgr2.h‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ private slots:
158158
void scheduleMainConfigWrite();
159159
void scheduleSessionConfigWrite();
160160

161+
// Put back the objects the user owns wholesale (e.g. widget.newNoteDefaultTemplates) after
162+
// the defaults merge: merge_patch merges objects per key, which would resurrect the bundled
163+
// entries of a map the user deliberately emptied. @p_raw is the unmerged document the merge
164+
// was built from - the only place that still knows whether the key was present on disk.
165+
static void restoreUserOwnedObjects(QJsonObject &p_merged, const QJsonObject &p_raw);
166+
161167
// Perform version upgrade of the config itself (version-gated forced
162168
// overrides + version stamping). The bundled extra-data dump is NOT part of
163169
// this; it is owned by ensureExtraData(), which runs on every launch.
@@ -185,6 +191,11 @@ private slots:
185191
// Whether version changed since last run
186192
bool m_versionChanged = false;
187193

194+
// Set when init() could not READ an existing main config (as opposed to there being none).
195+
// Suppresses every main-config write for the rest of the session so defaults cannot
196+
// overwrite a config we merely failed to read.
197+
bool m_mainConfigReadFailed = false;
198+
188199
// Folders whose extra-data install failed during the last ensureExtraData().
189200
QVector<ExtraDataFailure> m_extraDataFailures;
190201

‎src/core/iconfig.h‎

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,28 +92,15 @@ class IConfig {
9292
writeByteArray(p_obj, p_key, bytes);
9393
}
9494

95+
// NOTE: an absent key maps to false here, and that is safe ONLY because ConfigMgr2::init()
96+
// applies the user's vnotex.json as an RFC 7386 merge patch on top of the
97+
// default-constructed config's toJson(), so every key is present by the time fromJson()
98+
// runs. Do not feed a raw, unmerged document to a config's fromJson(), and do not construct
99+
// a throwaway MainConfig from ConfigCoreService::getConfigByName() JSON.
95100
static bool readBool(const QJsonObject &p_obj, const QString &p_key) {
96101
return p_obj.value(p_key).toBool();
97102
}
98103

99-
// Presence-aware overload, for a key whose default is NOT false.
100-
//
101-
// The plain readBool() above maps an absent key to false, which is correct only for a
102-
// false-default setting. ConfigMgr2::init() skips fromJson() only when the config file is
103-
// entirely absent, so on every existing installation a newly introduced key IS read - and a
104-
// true-default one would silently flip to false on upgrade. Use this overload for any
105-
// true-default key you ADD.
106-
//
107-
// The pre-existing true-default keys (markdowneditorconfig, widgetconfig, texteditorconfig,
108-
// coreconfig) still use the two-argument form. They are not exposed to the upgrade path
109-
// above, because any config file written by a version that had them also contains them;
110-
// only a hand-edited or truncated file can hit it. Converting them is a separate audit with
111-
// its own behavior change, deliberately not folded into the commit that added this overload.
112-
static bool readBool(const QJsonObject &p_obj, const QString &p_key, bool p_defaultValue) {
113-
const auto value = p_obj.value(p_key);
114-
return value.isBool() ? value.toBool() : p_defaultValue;
115-
}
116-
117104
static int readInt(const QJsonObject &p_obj, const QString &p_key) {
118105
return p_obj.value(p_key).toInt();
119106
}

‎src/core/mainconfig.cpp‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ MainConfig::MainConfig(IConfigMgr *p_mgr) : IConfig(p_mgr, nullptr) {
2323
MainConfig::~MainConfig() {}
2424

2525
void MainConfig::fromJson(const QJsonObject &p_jobj) {
26-
// p_jobj is already merged (defaults + user overrides)
26+
// p_jobj MUST already be merged (defaults + user overrides). ConfigMgr2::init() guarantees
27+
// this by applying the user's document as an RFC 7386 merge patch on top of the defaults;
28+
// the child fromJson()s below map an absent key to false/0/"", so a raw, unmerged document
29+
// would wipe every non-zero default.
2730
loadMetadata(p_jobj);
2831

2932
for (auto &childConfig : m_childConfigs) {

‎src/core/markdowneditorconfig.cpp‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,7 @@ void MarkdownEditorConfig::fromJson(const QJsonObject &p_jobj) {
5858
m_smartTableEnabled = READBOOL(QStringLiteral("smartTable"));
5959
m_smartTableInterval = READINT(QStringLiteral("smartTableInterval"));
6060
m_alignTableSourceEnabled = READBOOL(QStringLiteral("alignTableSource"));
61-
// Defaults to true, so it must not go through READBOOL: an absent key would turn
62-
// auto-folding off for every existing installation on upgrade.
63-
m_autoFoldPreviewedBlocksEnabled =
64-
readBool(p_jobj, QStringLiteral("autoFoldPreviewedBlocks"), true);
61+
m_autoFoldPreviewedBlocksEnabled = READBOOL(QStringLiteral("autoFoldPreviewedBlocks"));
6562

6663
m_spellCheckEnabled = READBOOL(QStringLiteral("spellCheck"));
6764

‎src/core/services/configcoreservice.cpp‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,12 @@ QJsonObject ConfigCoreService::getSessionConfig() const {
102102
return parseJsonObjectFromCStr(json);
103103
}
104104

105-
QJsonObject ConfigCoreService::getConfigByName(DataLocation p_location,
106-
const QString &p_baseName) const {
105+
QJsonObject ConfigCoreService::getConfigByName(DataLocation p_location, const QString &p_baseName,
106+
bool *p_ok) const {
107+
if (p_ok) {
108+
*p_ok = false;
109+
}
110+
107111
if (!checkContext()) {
108112
return QJsonObject();
109113
}
@@ -113,14 +117,41 @@ QJsonObject ConfigCoreService::getConfigByName(DataLocation p_location,
113117
vxcore_context_get_config_by_name(m_context, static_cast<VxCoreDataLocation>(p_location),
114118
p_baseName.toUtf8().constData(), &json);
115119
if (err != VXCORE_OK) {
120+
// An absent config is not a read failure: there is simply nothing there yet.
121+
if (p_ok) {
122+
*p_ok = (err == VXCORE_ERR_NOT_FOUND);
123+
}
116124
return QJsonObject();
117125
}
118-
return parseJsonObjectFromCStr(json);
126+
127+
// A payload that does not parse, or that is not a JSON OBJECT, is a read failure rather
128+
// than an empty config: silently returning {} would let a caller overwrite it with
129+
// defaults. An EMPTY payload is the exception - an empty file carries no settings to lose.
130+
const QByteArray payload = QByteArray(json).trimmed();
131+
vxcore_string_free(json);
132+
if (payload.isEmpty()) {
133+
if (p_ok) {
134+
*p_ok = true;
135+
}
136+
return QJsonObject();
137+
}
138+
139+
QJsonParseError parseError;
140+
const auto doc = QJsonDocument::fromJson(payload, &parseError);
141+
if (p_ok) {
142+
*p_ok = (parseError.error == QJsonParseError::NoError && doc.isObject());
143+
}
144+
return doc.object();
119145
}
120146

121147
QJsonObject ConfigCoreService::getConfigByNameWithDefaults(DataLocation p_location,
122148
const QString &p_baseName,
123-
const QJsonObject &p_defaults) const {
149+
const QJsonObject &p_defaults,
150+
bool *p_ok) const {
151+
if (p_ok) {
152+
*p_ok = false;
153+
}
154+
124155
if (!checkContext()) {
125156
return p_defaults;
126157
}
@@ -130,7 +161,15 @@ QJsonObject ConfigCoreService::getConfigByNameWithDefaults(DataLocation p_locati
130161
m_context, static_cast<VxCoreDataLocation>(p_location), p_baseName.toUtf8().constData(),
131162
QJsonDocument(p_defaults).toJson(QJsonDocument::Compact).constData(), &json);
132163
if (err != VXCORE_OK) {
133-
return QJsonObject();
164+
// vxcore already folded "absent" into VXCORE_OK + defaults, so reaching here means the
165+
// config exists but could not be read or parsed. Hand back the defaults (there is nothing
166+
// better to run on) but report the failure, so the caller can refuse to persist over it.
167+
qWarning() << "Failed to read config" << p_baseName << "with defaults: error=" << err;
168+
return p_defaults;
169+
}
170+
171+
if (p_ok) {
172+
*p_ok = true;
134173
}
135174
return parseJsonObjectFromCStr(json);
136175
}

‎src/core/services/configcoreservice.h‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,24 @@ class ConfigCoreService : public QObject, private Noncopyable {
4848
QJsonObject getSessionConfig() const;
4949

5050
// Get configuration by name for specified location.
51-
QJsonObject getConfigByName(DataLocation p_location, const QString &p_baseName) const;
52-
53-
// Get configuration by name with default values fallback.
51+
//
52+
// @p_ok, when given, reports whether the config could be READ. An absent file is not a
53+
// failure (it yields an empty object with *p_ok == true); a present-but-unreadable or
54+
// unparseable one sets *p_ok to false.
55+
QJsonObject getConfigByName(DataLocation p_location, const QString &p_baseName,
56+
bool *p_ok = nullptr) const;
57+
58+
// Get configuration by name, deep-merged (RFC 7386) on top of @p_defaults, so a key the
59+
// user's file does not carry keeps its default value.
60+
//
61+
// @p_ok, when given, reports whether the config could actually be READ. An absent file is
62+
// NOT a failure (it yields @p_defaults with *p_ok == true). A present-but-unreadable file
63+
// sets *p_ok to false and also yields @p_defaults - vxcore deliberately distinguishes the
64+
// two, and a caller that persists what it got back MUST check this flag or it will
65+
// overwrite a config it merely failed to read.
5466
QJsonObject getConfigByNameWithDefaults(DataLocation p_location, const QString &p_baseName,
55-
const QJsonObject &p_defaults) const;
67+
const QJsonObject &p_defaults,
68+
bool *p_ok = nullptr) const;
5669

5770
// Update configuration by name. Returns Error for failure cases.
5871
Error updateConfigByName(DataLocation p_location, const QString &p_baseName,

0 commit comments

Comments
 (0)