diff --git a/CMakeLists.txt b/CMakeLists.txt index 35dde83..be21d09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,7 @@ set(WIDGETS_UI_FILES set(TEST_HEADER_FILES) set(TEST_SRC_FILES tests/cli.cpp) +set(EXCLUDED_FILES cli/mediafileinfoobject.h cli/mediafileinfoobject.cpp) set(TS_FILES translations/${META_PROJECT_NAME}_de_DE.ts translations/${META_PROJECT_NAME}_en_US.ts) @@ -234,13 +235,21 @@ endif () # add Qt modules which can not be detected automatically list(APPEND ADDITIONAL_QT_MODULES Concurrent Network) -# include modules to apply configuration +# configure usage of Qt if (WIDGETS_GUI OR QUICK_GUI) include(QtGuiConfig) include(QtJsProviderConfig) include(QtWebViewProviderConfig) include(QtConfig) endif () + +# configure JavaScript processing for the CLI +if (JS_PROVIDER STREQUAL Qml) + list(APPEND HEADER_FILES cli/mediafileinfoobject.h) + list(APPEND SRC_FILES cli/mediafileinfoobject.cpp) +endif () + +# include modules to apply configuration include(WindowsResources) include(TestTarget) include(AppTarget) diff --git a/README.md b/README.md index 6522885..87df6b6 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,38 @@ Here are some Bash examples which illustrate getting and setting tag information - This is only supported by the tag formats ID3v2 and Vorbis Comment. The type and description are ignored when dealing with a different format. +* Sets fields by running a script to compute changes dynamically: + ``` + tageditor set --pedantic debug --java-script path/to/script.js -f foo.mp3 + ``` + + - This feature is still experimental. The script API is still very limited and subject to change. + - The script needs to be ECMAScript as supported by the Qt framework. + - This feature requires the tag editor to be configured with Qt QML as JavaScript provider at + compile time. Checkout the build instructions under "Building with Qt GUI" for details. + - It needs to export a `main()` function. This function gets executed for every file and passed + and object representing this file as first argument. + - The option `--pedantic debug` is not required but useful for debugging. + - Checkout the file `testfiles/set-tags.js` directory in this repository for a basic example. + - Common tag fields are exposed as object properties as shown in the mentioned example. + - Only properties for fields that are supported by the tag are added to the "fields" object. + - Adding properties of unsupported fields manually does not work; those will just be ignored. + - The content of fields that are absent in the tag is set to `undefined`. You may also set + the content of fields to `undefined` to delete them (`null` works as well). + - The content of binary fields is exposed as `ArrayBuffer`. Use must also use an `ArrayBuffer` + to set the value of binary fields such as the cover. + - The content of other fields is mostly exposed as `String` or `Number`. Use must also use + these types to set the value of those fields. The string-representation of the assigned + content will then be converted automatically to what's needed internally. + - The `utility` object exposes useful methods, e.g. for logging and controlling the event loop. + - Checkout the file `testfiles/http.js` in this repository for an example of using XHR and + controlling the event loop. + - The script is executed before any other modifications are applied. So if you also specify + values as usual (via `--values`) then these values override values changes by the script. + - The script runs so far before tags are added/removed (according to options like + `--id3v1-usage`). This may change in future versions. A JavaScript API to deal with such + changes still needs to be implemented. + ##### Further useful commands * Let the tag editor return with a non-zero exit code even if only non-fatal problems have been encountered * when saving a file: diff --git a/application/main.cpp b/application/main.cpp index 7b7285d..99cf215 100644 --- a/application/main.cpp +++ b/application/main.cpp @@ -83,6 +83,7 @@ SetTagInfoArgs::SetTagInfoArgs(Argument &filesArg, Argument &verboseArg, Argumen , backupDirArg("temp-dir", '\0', "specifies the directory for temporary/backup files", { "path" }) , layoutOnlyArg("layout-only", 'l', "confirms layout-only changes") , preserveModificationTimeArg("preserve-modification-time", '\0', "preserves the file's modification time") + , jsArg("java-script", 'j', "modifies tag fields via the specified JavaScript", { "path" }) , setTagInfoArg("set", 's', "sets the specified tag information and attachments") { docTitleArg.setRequiredValueCount(Argument::varValueCount); @@ -120,6 +121,7 @@ SetTagInfoArgs::SetTagInfoArgs(Argument &filesArg, Argument &verboseArg, Argumen valuesArg.setPreDefinedCompletionValues(Cli::fieldNamesForSet); valuesArg.setValueCompletionBehavior(ValueCompletionBehavior::PreDefinedValues | ValueCompletionBehavior::AppendEquationSign); outputFilesArg.setRequiredValueCount(Argument::varValueCount); + jsArg.setValueCompletionBehavior(ValueCompletionBehavior::Files); setTagInfoArg.setCallback(std::bind(Cli::setTagInfo, std::cref(*this))); setTagInfoArg.setExample(PROJECT_NAME " set title=\"Title of \"{1st,2nd,3rd}\" file\" title=\"Title of \"{4..16}\"th file\" album=\"The Album\" -f /some/dir/*.m4a\n" PROJECT_NAME @@ -135,7 +137,7 @@ SetTagInfoArgs::SetTagInfoArgs(Argument &filesArg, Argument &verboseArg, Argumen &id3v2UsageArg, &id3InitOnCreateArg, &id3TransferOnRemovalArg, &mergeMultipleSuccessiveTagsArg, &id3v2VersionArg, &encodingArg, &removeTargetArg, &addAttachmentArg, &updateAttachmentArg, &removeAttachmentArg, &removeExistingAttachmentsArg, &minPaddingArg, &maxPaddingArg, &prefPaddingArg, &tagPosArg, &indexPosArg, &forceRewriteArg, &backupDirArg, &layoutOnlyArg, &preserveModificationTimeArg, - &verboseArg, &pedanticArg, &quietArg, &outputFilesArg }); + &jsArg, &verboseArg, &pedanticArg, &quietArg, &outputFilesArg }); } } // namespace Cli diff --git a/cli/helper.cpp b/cli/helper.cpp index de559e2..634b35b 100644 --- a/cli/helper.cpp +++ b/cli/helper.cpp @@ -222,17 +222,25 @@ void printDiagMessages(const Diagnostics &diag, const char *head, bool beVerbose setStyle(cerr, TextAttribute::Reset); break; case DiagLevel::Critical: - case DiagLevel::Fatal: setStyle(cerr, Color::Red, ColorContext::Foreground, TextAttribute::Bold); setStyle(cerr, TextAttribute::Reset); setStyle(cerr, TextAttribute::Bold); cerr << " Error "; setStyle(cerr, TextAttribute::Reset); break; + case DiagLevel::Fatal: + setStyle(cerr, Color::Red, ColorContext::Foreground, TextAttribute::Bold); + setStyle(cerr, TextAttribute::Reset); + setStyle(cerr, TextAttribute::Bold); + cerr << " Fatal "; + setStyle(cerr, TextAttribute::Reset); + break; default:; } cerr << message.creationTime().toString(DateTimeOutputFormat::TimeOnly) << " "; - cerr << message.context() << ": "; + if (!message.context().empty()) { + cerr << message.context() << ": "; + } cerr << message.message() << '\n'; } } diff --git a/cli/mainfeatures.cpp b/cli/mainfeatures.cpp index d77c4f9..1315b1c 100644 --- a/cli/mainfeatures.cpp +++ b/cli/mainfeatures.cpp @@ -6,6 +6,13 @@ #endif #include "../application/knownfieldmodel.h" + +// includes for JavaScript support of set operation +#ifdef TAGEDITOR_USE_JSENGINE +#include "./mediafileinfoobject.h" +#endif + +// includes for generating HTML info #if defined(TAGEDITOR_GUI_QTWIDGETS) || defined(TAGEDITOR_GUI_QTQUICK) #include "../misc/htmlinfo.h" #include "../misc/utility.h" @@ -39,6 +46,16 @@ #include #include +// includes for JavaScript support of set operation +#ifdef TAGEDITOR_USE_JSENGINE +#include +#include +#include +#include +#include +#endif + +// includes for generating HTML info #if defined(TAGEDITOR_GUI_QTWIDGETS) || defined(TAGEDITOR_GUI_QTQUICK) #include #include @@ -471,6 +488,110 @@ template static void setId3v2CoverValues(TagType *tag, std::vect } } +#ifdef TAGEDITOR_USE_JSENGINE +class JavaScriptProcessor { +public: + explicit JavaScriptProcessor(const SetTagInfoArgs &args); + JavaScriptProcessor(const JavaScriptProcessor &) = delete; + + QJSValue callMain(MediaFileInfo &mediaFileInfo, Diagnostics &diag); + +private: + static void addWarnings(Diagnostics &diag, const std::string &context, const QList &warnings); + + int argc; + QCoreApplication app; + Diagnostics diag; + QQmlEngine engine; // not using QJSEngine as otherwise XMLHttpRequest is not available + QJSValue module, main; + UtilityObject *utility; +}; + +/*! + * \brief Initializes JavaScript processing for the specified \a args. + * \remarks + * - Comes with its own QCoreApplication. Only for use within the CLI parts of the app! + * - Exits the app on fatal errors. + * - Logs status/problems directly in accordance with other parts of the CLI. + */ +JavaScriptProcessor::JavaScriptProcessor(const SetTagInfoArgs &args) + : argc(0) + , app(argc, nullptr) + , utility(new UtilityObject(&engine)) +{ + // print status message + const auto jsPath = args.jsArg.firstValue(); + if (!jsPath) { + return; + } + if (!args.quietArg.isPresent()) { + std::cout << TextAttribute::Bold << "Loading JavaScript file \"" << jsPath << "\" ..." << Phrases::EndFlush; + } + + // print warnings later via the usual helper function for consistent formatting + engine.setOutputWarningsToStandardError(false); + QObject::connect(&engine, &QQmlEngine::warnings, &engine, + [this, context = std::string("loading JavaScript")](const auto &warnings) { addWarnings(diag, context, warnings); }); + + // assign utility object and load specified JavaScript file as module + engine.globalObject().setProperty(QStringLiteral("utility"), engine.newQObject(utility)); + module = engine.importModule(QString::fromUtf8(jsPath)); + if (module.isError()) { + std::cerr << Phrases::Error << "Unable to load the specified JavaScript file \"" << jsPath << "\":" << Phrases::End; + std::cerr << "Uncaught exception at line " << module.property(QStringLiteral("lineNumber")).toInt() << ':' << ' ' + << module.toString().toStdString() << '\n'; + std::exit(EXIT_FAILURE); + } + main = module.property(QStringLiteral("main")); + if (!main.isCallable()) { + std::cerr << Phrases::Error << "The specified JavaScript file \"" << jsPath << "\" does not export a main() function." << Phrases::End; + std::exit(EXIT_FAILURE); + } + + // print warnings + printDiagMessages(diag, "Diagnostic messages:", args.verboseArg.isPresent(), &args.pedanticArg); + diag.clear(); +} + +/*! + * \brief Calls the JavaScript's main() function for the specified \a mediaFileInfo populating \a diag. + * \returns Returns what the main() function has returned. + */ +QJSValue JavaScriptProcessor::callMain(MediaFileInfo &mediaFileInfo, Diagnostics &diag) +{ + auto fileInfoObject = MediaFileInfoObject(mediaFileInfo, diag, &engine); + auto fileInfoObjectValue = engine.newQObject(&fileInfoObject); + auto context = argsToString("executing JavaScript for ", mediaFileInfo.fileName()); + utility->setDiag(&context, &diag); + QObject::connect( + &engine, &QQmlEngine::warnings, &fileInfoObject, [&diag, &context](const auto &warnings) { addWarnings(diag, context, warnings); }); + diag.emplace_back(DiagLevel::Information, "entering main() function", context); + auto res = main.call(QJSValueList({ fileInfoObjectValue })); + if (res.isError()) { + diag.emplace_back(DiagLevel::Fatal, + argsToString(res.toString().toStdString(), " at line ", res.property(QStringLiteral("lineNumber")).toInt(), '.'), context); + } else if (!res.isUndefined()) { + diag.emplace_back(DiagLevel::Information, argsToString("done with return value: ", res.toString().toStdString()), context); + } else { + diag.emplace_back(DiagLevel::Debug, "done without return value", context); + } + return res; +} + +/*! + * \brief Adds the \a warnings to the specified \a diag object with the specified \a context. + */ +void JavaScriptProcessor::addWarnings(Diagnostics &diag, const string &context, const QList &warnings) +{ + for (const auto &warning : warnings) { + diag.emplace_back(DiagLevel::Warning, warning.toString().toStdString(), context); + } +} +#endif + +/*! + * \brief Implements the "set"-operation of the CLI. + */ void setTagInfo(const SetTagInfoArgs &args) { CMD_UTILS_START_CONSOLE; @@ -496,7 +617,7 @@ void setTagInfo(const SetTagInfoArgs &args) && (!args.updateAttachmentArg.isPresent() || args.updateAttachmentArg.values().empty()) && (!args.removeAttachmentArg.isPresent() || args.removeAttachmentArg.values().empty()) && (!args.docTitleArg.isPresent() || args.docTitleArg.values().empty()) && !args.id3v1UsageArg.isPresent() && !args.id3v2UsageArg.isPresent() - && !args.id3v2VersionArg.isPresent()) { + && !args.id3v2VersionArg.isPresent() && !args.jsArg.isPresent()) { if (!args.layoutOnlyArg.isPresent()) { std::cerr << Phrases::Error << "No fields/attachments have been specified." << Phrases::End << "note: This is usually a mistake. Use --layout-only to prevent this error and apply file layout options only." << endl; @@ -556,12 +677,13 @@ void setTagInfo(const SetTagInfoArgs &args) } // parse other settings - const TagTextEncoding denotedEncoding = parseEncodingDenotation(args.encodingArg, TagTextEncoding::Utf8); + const auto denotedEncoding = parseEncodingDenotation(args.encodingArg, TagTextEncoding::Utf8); settings.id3v1usage = parseUsageDenotation(args.id3v1UsageArg, TagUsage::KeepExisting); settings.id3v2usage = parseUsageDenotation(args.id3v2UsageArg, TagUsage::Always); // setup media file info - MediaFileInfo fileInfo; + auto fileInfo = MediaFileInfo(); + auto tags = std::vector(); fileInfo.setMinPadding(parseUInt64(args.minPaddingArg, 0)); fileInfo.setMaxPadding(parseUInt64(args.maxPaddingArg, 0)); fileInfo.setPreferredPadding(parseUInt64(args.prefPaddingArg, 0)); @@ -577,10 +699,27 @@ void setTagInfo(const SetTagInfoArgs &args) fileInfo.setBackupDirectory(std::string(args.backupDirArg.values().front())); } + // initialize JavaScript processing if --java-script argument is present +#ifdef TAGEDITOR_USE_JSENGINE + auto js = args.jsArg.isPresent() ? std::make_unique(args) : std::unique_ptr(); +#else + if (args.jsArg.isPresent()) { + std::cerr << Phrases::Error << "A JavaScript has been specified but support for this has been disabled at compile-time." << Phrases::EndFlush; + std::exit(EXIT_FAILURE); + } +#endif + // iterate through all specified files const auto quiet = args.quietArg.isPresent(); auto fileIndex = 0u; static auto context = std::string("setting tags"); + const auto continueWithNextFile = [&](Diagnostics &diag) { + printDiagMessages(diag, "Diagnostic messages:", args.verboseArg.isPresent(), &args.pedanticArg); + ++fileIndex; + if (currentOutputFile != noMoreOutputFiles) { + ++currentOutputFile; + } + }; for (const char *file : args.filesArg.values()) { auto diag = Diagnostics(); auto parsingProgress = AbortableProgressFeedback(); // FIXME: actually use the progress object @@ -595,16 +734,36 @@ void setTagInfo(const SetTagInfoArgs &args) fileInfo.parseTracks(diag, parsingProgress); fileInfo.parseAttachments(diag, parsingProgress); + // process tag fields via the specified JavaScript +#ifdef TAGEDITOR_USE_JSENGINE + if (js) { + const auto res = js->callMain(fileInfo, diag); + if (res.isError() || diag.has(DiagLevel::Fatal)) { + if (!quiet) { + std::cout << " - Skipping file due to fatal error when executing JavaScript.\n"; + } + continueWithNextFile(diag); + continue; + } + if (!res.isUndefined() && !res.toBool()) { + if (!quiet) { + std::cout << " - Skipping file because JavaScript returned a falsy value other than undefined.\n"; + } + continueWithNextFile(diag); + continue; + } + } +#endif + // remove tags with the specified targets - auto tags = std::vector(); if (!targetsToRemove.empty()) { + tags.clear(); fileInfo.tags(tags); for (auto *const tag : tags) { if (find(targetsToRemove.cbegin(), targetsToRemove.cend(), tag->target()) != targetsToRemove.cend()) { fileInfo.removeTag(tag); } } - tags.clear(); } // select the relevant values for the current file index @@ -670,6 +829,7 @@ void setTagInfo(const SetTagInfoArgs &args) } // alter tags + tags.clear(); fileInfo.tags(tags); if (tags.empty()) { diag.emplace_back(DiagLevel::Critical, "Can not create appropriate tags for file.", context); @@ -958,14 +1118,7 @@ void setTagInfo(const SetTagInfoArgs &args) << Phrases::EndFlush; exitCode = EXIT_IO_FAILURE; } - - printDiagMessages(diag, "Diagnostic messages:", args.verboseArg.isPresent(), &args.pedanticArg); - - // continue with next file - ++fileIndex; - if (currentOutputFile != noMoreOutputFiles) { - ++currentOutputFile; - } + continueWithNextFile(diag); } } @@ -1221,4 +1374,5 @@ void applyGeneralConfig(const Argument &timeSapnFormatArg) { timeSpanOutputFormat = parseTimeSpanOutputFormat(timeSapnFormatArg, TimeSpanOutputFormat::WithMeasures); } + } // namespace Cli diff --git a/cli/mainfeatures.h b/cli/mainfeatures.h index 7080796..3a46e05 100644 --- a/cli/mainfeatures.h +++ b/cli/mainfeatures.h @@ -48,6 +48,7 @@ struct SetTagInfoArgs { CppUtilities::ConfigValueArgument backupDirArg; CppUtilities::ConfigValueArgument layoutOnlyArg; CppUtilities::ConfigValueArgument preserveModificationTimeArg; + CppUtilities::ConfigValueArgument jsArg; CppUtilities::OperationArgument setTagInfoArg; }; diff --git a/cli/mediafileinfoobject.cpp b/cli/mediafileinfoobject.cpp new file mode 100644 index 0000000..9aaf944 --- /dev/null +++ b/cli/mediafileinfoobject.cpp @@ -0,0 +1,319 @@ +#include "./mediafileinfoobject.h" +#include "./fieldmapping.h" + +#include "../application/knownfieldmodel.h" +#include "../misc/utility.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace Cli { + +constexpr auto nativeUtf16Encoding = TagParser::TagTextEncoding:: +#if defined(CONVERSION_UTILITIES_BYTE_ORDER_LITTLE_ENDIAN) + Utf16LittleEndian +#else + Utf16BigEndian +#endif + ; + +UtilityObject::UtilityObject(QJSEngine *engine) + : QObject(engine) + , m_context(nullptr) + , m_diag(nullptr) +{ +} + +void UtilityObject::log(const QString &message) +{ + std::cout << message.toStdString() << std::endl; +} + +void UtilityObject::diag(const QString &level, const QString &message, const QString &context) +{ + if (!m_diag) { + return; + } + static const auto mapping = QHash({ + { QStringLiteral("fatal"), TagParser::DiagLevel::Fatal }, + { QStringLiteral("critical"), TagParser::DiagLevel::Critical }, + { QStringLiteral("error"), TagParser::DiagLevel::Critical }, + { QStringLiteral("warning"), TagParser::DiagLevel::Warning }, + { QStringLiteral("info"), TagParser::DiagLevel::Information }, + { QStringLiteral("information"), TagParser::DiagLevel::Information }, + { QStringLiteral("debug"), TagParser::DiagLevel::Debug }, + }); + static const auto defaultContext = std::string("executing JavaScript"); + m_diag->emplace_back(mapping.value(level.toLower(), TagParser::DiagLevel::Debug), message.toStdString(), + context.isEmpty() ? (m_context ? *m_context : defaultContext) : context.toStdString()); +} + +int UtilityObject::exec() +{ + return QCoreApplication::exec(); +} + +void UtilityObject::exit(int retcode) +{ + QCoreApplication::exit(retcode); +} + +QString UtilityObject::readEnvironmentVariable(const QString &variable, const QString &defaultValue) const +{ + return qEnvironmentVariable(variable.toUtf8().data(), defaultValue); +} + +QString UtilityObject::formatName(const QString &str) const +{ + return Utility::formatName(str); +} + +QString UtilityObject::fixUmlauts(const QString &str) const +{ + return Utility::fixUmlauts(str); +} + +TagValueObject::TagValueObject(const TagParser::TagValue &value, QJSEngine *engine, QObject *parent) + : QObject(parent) + , m_type(QString::fromUtf8(TagParser::tagDataTypeString(value.type()))) + , m_initial(true) +{ + switch (value.type()) { + case TagParser::TagDataType::Undefined: + break; + case TagParser::TagDataType::Text: + case TagParser::TagDataType::PositionInSet: + case TagParser::TagDataType::Popularity: + case TagParser::TagDataType::DateTime: + case TagParser::TagDataType::DateTimeExpression: + case TagParser::TagDataType::TimeSpan: + m_content = Utility::tagValueToQString(value); + break; + case TagParser::TagDataType::Integer: + m_content = value.toInteger(); + break; + case TagParser::TagDataType::UnsignedInteger: + if (auto v = value.toUnsignedInteger(); v < std::numeric_limits::max()) { + m_content = QJSValue(static_cast(v)); + } else { + m_content = QString::number(v); + } + break; + case TagParser::TagDataType::Binary: + case TagParser::TagDataType::Picture: + m_content = engine->toScriptValue(QByteArray(value.dataPointer(), Utility::sizeToInt(value.dataSize()))); + break; + default: + m_content = QJSValue::NullValue; + } +} + +TagValueObject::~TagValueObject() +{ +} + +const QString &TagValueObject::type() const +{ + return m_type; +} + +const QJSValue &TagValueObject::content() const +{ + return m_content; +} + +const QJSValue &TagValueObject::initialContent() const +{ + return m_initial ? m_content : m_initialContent; +} + +void TagValueObject::setContent(const QJSValue &content) +{ + if (m_initial) { + m_initialContent = m_content; + m_initial = false; + } + m_content = content; +} + +bool TagValueObject::isInitial() const +{ + return m_initial; +} + +TagParser::TagValue TagValueObject::toTagValue(TagParser::TagTextEncoding encoding) const +{ + if (m_content.isUndefined() || m_content.isNull()) { + return TagParser::TagValue(); + } + const auto str = m_content.toString(); + return TagParser::TagValue(reinterpret_cast(str.utf16()), static_cast(str.size()) * (sizeof(ushort) / sizeof(char)), + nativeUtf16Encoding, encoding); +} + +void TagValueObject::restore() +{ + if (!m_initial) { + m_content = m_initialContent; + m_initial = true; + } +} + +void TagValueObject::clear() +{ + setContent(QJSValue()); +} + +TagObject::TagObject(TagParser::Tag &tag, TagParser::Diagnostics &diag, QJSEngine *engine, QObject *parent) + : QObject(parent) + , m_tag(tag) + , m_diag(diag) + , m_engine(engine) +{ +} + +TagObject::~TagObject() +{ +} + +QString TagObject::type() const +{ + return Utility::qstr(m_tag.typeName()); +} + +QJSValue &TagObject::fields() +{ + if (!m_fields.isUndefined()) { + return m_fields; + } + static const auto fieldRegex = QRegularExpression(QStringLiteral("\\s(\\w)")); + m_fields = m_engine->newObject(); + for (auto field = TagParser::firstKnownField; field != TagParser::KnownField::Invalid; field = TagParser::nextKnownField(field)) { + if (!m_tag.supportsField(field)) { + continue; + } + if (const auto propertyName = propertyNameForField(field); !propertyName.isEmpty()) { + m_fields.setProperty(propertyName, m_engine->newQObject(new TagValueObject(m_tag.value(field), m_engine, this))); + } + } + return m_fields; +} + +QString TagObject::propertyNameForField(TagParser::KnownField field) +{ + static const auto reverseMapping = [] { + auto reverse = QHash(); + for (const auto &mapping : FieldMapping::mapping()) { + auto d = QString::fromUtf8(mapping.knownDenotation); + if (d.isUpper()) { + d = d.toLower(); // turn abbreviations into just lower case + } else { + d.front() = d.front().toLower(); + } + reverse[mapping.knownField] = std::move(d); + } + return reverse; + }(); + return reverseMapping.value(field, QString()); +} + +void TagObject::applyChanges() +{ + auto context = !m_tag.target().isEmpty() || m_tag.type() == TagParser::TagType::MatroskaTag + ? CppUtilities::argsToString(m_tag.typeName(), " targeting ", m_tag.targetString()) + : std::string(m_tag.typeName()); + m_diag.emplace_back(TagParser::DiagLevel::Debug, "applying changes", std::move(context)); + if (m_fields.isUndefined()) { + return; + } + const auto encoding = m_tag.proposedTextEncoding(); + for (auto field = TagParser::firstKnownField; field != TagParser::KnownField::Invalid; field = TagParser::nextKnownField(field)) { + if (!m_tag.supportsField(field)) { + continue; + } + const auto propertyName = propertyNameForField(field); + if (propertyName.isEmpty()) { + continue; + } + auto propertyValue = m_fields.property(propertyName); + auto fieldDisplayName = Settings::KnownFieldModel::fieldName(field); + if (const auto *const tagValueObj = qobject_cast(propertyValue.toQObject())) { + if (!tagValueObj->isInitial()) { + auto value = tagValueObj->toTagValue(encoding); + m_diag.emplace_back(TagParser::DiagLevel::Debug, + value.isNull() + ? CppUtilities::argsToString(" - delete '", fieldDisplayName, '\'') + : CppUtilities::argsToString(" - change '", fieldDisplayName, "' from '", + tagValueObj->initialContent().toString().toStdString(), "' to '", tagValueObj->content().toString().toStdString(), '\''), + std::string()); + m_tag.setValue(field, std::move(value)); + } + } else { + m_engine->throwError(QJSValue::TypeError, QStringLiteral("invalid value assigned to field ") + propertyName); + } + } +} + +MediaFileInfoObject::MediaFileInfoObject(TagParser::MediaFileInfo &mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, QObject *parent) + : QObject(parent) + , m_f(mediaFileInfo) + , m_diag(diag) + , m_engine(engine) +{ +} + +MediaFileInfoObject::~MediaFileInfoObject() +{ +} + +QString MediaFileInfoObject::currentPath() const +{ + return QString::fromStdString(m_f.path()); +} + +QString MediaFileInfoObject::currentName() const +{ + return QString::fromStdString(m_f.fileName()); +} + +QList &MediaFileInfoObject::tags() +{ + if (!m_tags.isEmpty()) { + return m_tags; + } + auto tags = m_f.tags(); + m_tags.reserve(Utility::sizeToInt(tags.size())); + for (auto *const tag : tags) { + m_tags << new TagObject(*tag, m_diag, m_engine, this); + } + return m_tags; +} + +void MediaFileInfoObject::applyChanges() +{ + for (auto *const tag : m_tags) { + tag->applyChanges(); + } +} + +} // namespace Cli diff --git a/cli/mediafileinfoobject.h b/cli/mediafileinfoobject.h new file mode 100644 index 0000000..39db4ed --- /dev/null +++ b/cli/mediafileinfoobject.h @@ -0,0 +1,152 @@ +#ifndef CLI_MEDIA_FILE_INFO_OBJECT_H +#define CLI_MEDIA_FILE_INFO_OBJECT_H + +#include +#include + +QT_FORWARD_DECLARE_CLASS(QJSEngine) + +namespace TagParser { +class Diagnostics; +class MediaFileInfo; +class Tag; +class TagValue; +enum class KnownField : unsigned int; +enum class TagTextEncoding : unsigned int; +} // namespace TagParser + +namespace Cli { + +/*! + * \brief The UtilityObject class wraps useful functions of Qt, TagParser and the Utility namespace for use within QML. + */ +class UtilityObject : public QObject { + Q_OBJECT + +public: + explicit UtilityObject(QJSEngine *engine); + + void setDiag(const std::string *context, TagParser::Diagnostics *diag); + +public Q_SLOTS: + void log(const QString &message); + void diag(const QString &level, const QString &message, const QString &context = QString()); + int exec(); + void exit(int retcode); + QString readEnvironmentVariable(const QString &variable, const QString &defaultValue = QString()) const; + QString formatName(const QString &str) const; + QString fixUmlauts(const QString &str) const; + +private: + const std::string *m_context; + TagParser::Diagnostics *m_diag; +}; + +inline void UtilityObject::setDiag(const std::string *context, TagParser::Diagnostics *diag) +{ + m_context = context; + m_diag = diag; +} + +/*! + * \brief The TagValueObject class wraps a TagParser::TagValue for use within QML. + */ +class TagValueObject : public QObject { + Q_OBJECT + Q_PROPERTY(QString type READ type) + Q_PROPERTY(QJSValue content READ content WRITE setContent) + Q_PROPERTY(QJSValue initialContent READ initialContent) + Q_PROPERTY(bool initial READ isInitial) + +public: + explicit TagValueObject(const TagParser::TagValue &value, QJSEngine *engine, QObject *parent); + ~TagValueObject() override; + + const QString &type() const; + const QJSValue &content() const; + void setContent(const QJSValue &content); + const QJSValue &initialContent() const; + bool isInitial() const; + TagParser::TagValue toTagValue(TagParser::TagTextEncoding encoding) const; + +public Q_SLOTS: + void restore(); + void clear(); + +private: + QString m_type; + QJSValue m_content; + QJSValue m_initialContent; + bool m_initial; +}; + +/*! + * \brief The TagObject class wraps a TagParser::Tag for use within QML. + */ +class TagObject : public QObject { + Q_OBJECT + Q_PROPERTY(QString type READ type) + Q_PROPERTY(QJSValue fields READ fields) + +public: + explicit TagObject(TagParser::Tag &tag, TagParser::Diagnostics &diag, QJSEngine *engine, QObject *parent); + ~TagObject() override; + + TagParser::Tag &tag(); + QString type() const; + QJSValue &fields(); + +public Q_SLOTS: + void applyChanges(); + +private: + static QString propertyNameForField(TagParser::KnownField field); + + TagParser::Tag &m_tag; + TagParser::Diagnostics &m_diag; + QJSEngine *m_engine; + QString m_type; + QJSValue m_fields; +}; + +inline TagParser::Tag &TagObject::tag() +{ + return m_tag; +} + +/*! + * \brief The MediaFileInfoObject class wraps a TagParser::MediaFileInfo for use within QML. + */ +class MediaFileInfoObject : public QObject { + Q_OBJECT + Q_PROPERTY(QString currentPath READ currentPath) + Q_PROPERTY(QString currentName READ currentName) + Q_PROPERTY(QList tags READ tags) + +public: + explicit MediaFileInfoObject(TagParser::MediaFileInfo &mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, QObject *parent = nullptr); + ~MediaFileInfoObject() override; + + TagParser::MediaFileInfo &fileInfo(); + QString currentPath() const; + QString currentName() const; + QList &tags(); + +public Q_SLOTS: + void applyChanges(); + +private: + TagParser::MediaFileInfo &m_f; + TagParser::Diagnostics &m_diag; + QJSEngine *m_engine; + QList m_tags; +}; + +inline TagParser::MediaFileInfo &MediaFileInfoObject::fileInfo() +{ + return m_f; +} + +} // namespace Cli + +#endif // CLI_MEDIA_FILE_INFO_OBJECT_H diff --git a/testfiles/http.js b/testfiles/http.js new file mode 100644 index 0000000..0d8e54c --- /dev/null +++ b/testfiles/http.js @@ -0,0 +1,23 @@ +export function query(method, url) { + let request = new XMLHttpRequest(); + let result = null; + request.onreadystatechange = function() { + if (request.readyState === XMLHttpRequest.DONE) { + utility.exit(0); + result = { + status: request.status, + headers: request.getAllResponseHeaders(), + contentType: request.responseType, + content: request.response + }; + } + }; + request.open(method, url); + request.send(); + utility.exec(); + return result; +} + +export function get(url) { + return query("GET", url); +} diff --git a/testfiles/set-tags.js b/testfiles/set-tags.js new file mode 100644 index 0000000..9389b8f --- /dev/null +++ b/testfiles/set-tags.js @@ -0,0 +1,36 @@ +// import another module as an example how imports work +import * as http from "http.js" + +export function main(file) { + // iterate though all tags of the file to change fields in all of them + for (const tag of file.tags) { + changeTagFields(tag); + } + + // submit changes from the JavaScript-context to the tag editor application; does not save changes to disk yet + file.applyChanges(); + + // return a falsy value to skip the file after all + return false; +} + +function changeTagFields(tag) { + // log supported fields + const fields = tag.fields; + utility.diag("debug", tag.type, "tag"); + utility.diag("debug", Object.keys(fields).join(", "), "supported fields"); + + // log tag type and fields for debugging purposes + for (const [key, value] of Object.entries(fields)) { + const content = value.content; + if (content !== undefined && content != null && !(content instanceof ArrayBuffer)) { + utility.diag("debug", content, key + " (" + value.type + ")"); + } + } + + // change some fields + fields.title.content = "foo"; + fields.artist.content = "bar"; + fields.track.content = "4/17"; + fields.comment.clear(); +}