| 1319 | } |
| 1320 | |
| 1321 | QString ThemeManager::importThemeFromFile(const QString& filePath, |
| 1322 | QString* errorMessage) |
| 1323 | { |
| 1324 | auto fail = [errorMessage](const QString& msg) -> QString { |
| 1325 | if (errorMessage) *errorMessage = msg; |
| 1326 | return QString(); |
| 1327 | }; |
| 1328 | if (filePath.isEmpty()) return fail(QStringLiteral("Empty file path.")); |
| 1329 | |
| 1330 | QFile in(filePath); |
| 1331 | if (!in.open(QIODevice::ReadOnly)) |
| 1332 | return fail(QStringLiteral("Cannot open file: %1").arg(in.errorString())); |
| 1333 | QJsonParseError perr; |
| 1334 | const auto doc = QJsonDocument::fromJson(in.readAll(), &perr); |
| 1335 | in.close(); |
| 1336 | if (perr.error != QJsonParseError::NoError || !doc.isObject()) |
| 1337 | return fail(QStringLiteral("File is not a valid theme JSON: %1").arg(perr.errorString())); |
| 1338 | |
| 1339 | const QJsonObject root = doc.object(); |
| 1340 | const int schemaVersion = root.value(QStringLiteral("schemaVersion")).toInt(0); |
| 1341 | if (schemaVersion <= 0) |
| 1342 | return fail(QStringLiteral("Missing or invalid schemaVersion — file may not be a theme.")); |
| 1343 | if (schemaVersion > 1) { |
| 1344 | // Forward-compatible-ish: load anyway, but warn the operator. |
| 1345 | // Unknown tokens round-trip; missing tokens fall back to factory. |
| 1346 | qCWarning(lcGui) << "ThemeManager::importThemeFromFile: schemaVersion" |
| 1347 | << schemaVersion << "is newer than this build supports;" |
| 1348 | << "loading anyway with unknown tokens preserved."; |
| 1349 | } |
| 1350 | |
| 1351 | // Pick a destination name: prefer the JSON's "name" field, fall back |
| 1352 | // to the file's basename. Sanitise so we can't path-traverse via |
| 1353 | // a malicious name field — slashes get replaced with underscores. |
| 1354 | QString name = root.value(QStringLiteral("name")).toString().trimmed(); |
| 1355 | if (name.isEmpty()) name = QFileInfo(filePath).completeBaseName(); |
| 1356 | name.replace(QLatin1Char('/'), QLatin1Char('_')); |
| 1357 | name.replace(QLatin1Char('\\'), QLatin1Char('_')); |
| 1358 | if (name.isEmpty()) return fail(QStringLiteral("Cannot derive a theme name from the file.")); |
| 1359 | |
| 1360 | // Refuse to clobber a built-in theme by name — the user can't edit |
| 1361 | // those anyway and the resource-bundle copy shadows whatever lands |
| 1362 | // on disk, which would be confusing. |
| 1363 | if (isBuiltInTheme(name)) |
| 1364 | return fail(QStringLiteral("\"%1\" matches a built-in theme name. " |
| 1365 | "Rename the file's \"name\" field or " |
| 1366 | "rename the file itself.").arg(name)); |
| 1367 | |
| 1368 | const QString userDir = QStandardPaths::writableLocation( |
| 1369 | QStandardPaths::GenericConfigLocation) |
| 1370 | + QStringLiteral("/AetherSDR/themes"); |
| 1371 | QDir().mkpath(userDir); |
| 1372 | const QString destPath = userDir + QLatin1Char('/') |
| 1373 | + name + QStringLiteral(".json"); |
| 1374 | |
| 1375 | // If a same-named user theme already exists, append a numeric suffix |
| 1376 | // instead of overwriting — operators sharing themes shouldn't have |
| 1377 | // to be paranoid about collisions destroying their existing work. |
| 1378 | QString finalName = name; |