/********************************************************************** Audacity: A Digital Audio Editor MacroCommands.cpp Dominic Mazzoni James Crook ********************************************************************//*! \class MacroCommands \brief Maintains the list of commands for batch/macro processing. See also MacrosWindow and ApplyMacroDialog. *//*******************************************************************/ #define wxLOG_COMPONENT "MacroCommands" #include "BatchCommands.h" #include #include #include #include #include #include "Project.h" #include "ProjectAudioManager.h" #include "ProjectHistory.h" #include "ProjectSettings.h" #include "ProjectWindow.h" #include "commands/CommandManager.h" #include "effects/EffectManager.h" #include "effects/EffectUI.h" #include "FileNames.h" #include "Menus.h" #include "PluginManager.h" #include "Prefs.h" #include "SelectUtilities.h" #include "Shuttle.h" #include "Track.h" #include "UndoManager.h" #include "AllThemeResources.h" #include "widgets/AudacityMessageBox.h" #include "commands/CommandContext.h" MacroCommands::MacroCommands( AudacityProject &project ) : mProject{ project } , mExporter{ project } { ResetMacro(); auto names = GetNames(); auto defaults = GetNamesOfDefaultMacros(); for( size_t i = 0;i= (int)mCommandMacro.size()) { return wxT(""); } return mCommandMacro[index]; } wxString MacroCommands::GetParams(int index) { if (index < 0 || index >= (int)mParamsMacro.size()) { return wxT(""); } return mParamsMacro[index]; } int MacroCommands::GetCount() { return (int)mCommandMacro.size(); } wxString MacroCommands::ReadMacro(const wxString & macro, wxWindow *parent) { // Clear any previous macro ResetMacro(); // Build the filename wxFileName name(FileNames::MacroDir(), macro, wxT("txt")); // But, ask the user for the real name if we're importing if (parent) { FilePath fn = FileNames::SelectFile(FileNames::Operation::_None, XO("Import Macro"), wxEmptyString, name.GetName(), wxT("txt"), { FileNames::TextFiles }, wxFD_OPEN | wxRESIZE_BORDER, parent); // User canceled... if (fn.empty()) { return wxEmptyString; } wxFileName check(fn); check.SetPath(name.GetPath()); if (check.FileExists()) { int id = AudacityMessageBox( XO("Macro %s already exists. Would you like to replace it?").Format(check.GetName()), XO("Import Macro"), wxYES_NO); if (id == wxNO) { return wxEmptyString; } } name.Assign(fn); } // Set the file name wxTextFile tf(name.GetFullPath()); // Open and check tf.Open(); if (!tf.IsOpened()) { // wxTextFile will display any errors return wxEmptyString; } // Load commands from the file int lines = tf.GetLineCount(); if (lines > 0) { for (int i = 0; i < lines; i++) { // Find the command name terminator...ignore line if not found int splitAt = tf[i].Find(wxT(':')); if (splitAt < 0) { continue; } // Parse and clean wxString cmd = tf[i].Left(splitAt).Strip(wxString::both); wxString parm = tf[i].Mid(splitAt + 1).Strip(wxString::trailing); // Add to lists mCommandMacro.push_back(cmd); mParamsMacro.push_back(parm); } } // Done with the file tf.Close(); // Write to macro directory if importing if (parent) { return WriteMacro(name.GetName()); } return name.GetName(); } wxString MacroCommands::WriteMacro(const wxString & macro, wxWindow *parent) { // Build the default filename wxFileName name(FileNames::MacroDir(), macro, wxT("txt")); // But, ask the user for the real name if we're exporting if (parent) { FilePath fn = FileNames::SelectFile(FileNames::Operation::_None, XO("Export Macro"), wxEmptyString, name.GetName(), wxT("txt"), { FileNames::TextFiles }, wxFD_SAVE | wxFD_OVERWRITE_PROMPT | wxRESIZE_BORDER, parent); // User canceled... if (fn.empty()) { return wxEmptyString; } name.Assign(fn); } // Set the file name wxTextFile tf(name.GetFullPath()); // Create the file (Create() doesn't leave the file open) if (!tf.Exists()) { tf.Create(); } // Open it tf.Open(); if (!tf.IsOpened()) { // wxTextFile will display any errors return wxEmptyString; } // Start with a clean slate tf.Clear(); // Copy over the commands int lines = mCommandMacro.size(); for (int i = 0; i < lines; i++) { // using GET to serialize macro definition to a text file tf.AddLine(mCommandMacro[i].GET() + wxT(":") + mParamsMacro[ i ]); } // Write the macro tf.Write(); // Done with the file tf.Close(); return name.GetName(); } bool MacroCommands::AddMacro(const wxString & macro) { // Build the filename wxFileName name(FileNames::MacroDir(), macro, wxT("txt")); // Set the file name wxTextFile tf(name.GetFullPath()); // Create it..Create will display errors return tf.Create(); } bool MacroCommands::DeleteMacro(const wxString & macro) { // Build the filename wxFileName name(FileNames::MacroDir(), macro, wxT("txt")); // Delete it...wxRemoveFile will display errors auto result = wxRemoveFile(name.GetFullPath()); // Delete any legacy chain that it shadowed auto oldPath = wxFileName{ FileNames::LegacyChainDir(), macro, wxT("txt") }; wxRemoveFile(oldPath.GetFullPath()); // Don't care about this return value return result; } bool MacroCommands::RenameMacro(const wxString & oldmacro, const wxString & newmacro) { // Build the filenames wxFileName oname(FileNames::MacroDir(), oldmacro, wxT("txt")); wxFileName nname(FileNames::MacroDir(), newmacro, wxT("txt")); // Rename it...wxRenameFile will display errors return wxRenameFile(oname.GetFullPath(), nname.GetFullPath()); } // Gets all commands that are valid for this mode. MacroCommandsCatalog::MacroCommandsCatalog( const AudacityProject *project ) { if (!project) return; Entries commands; PluginManager & pm = PluginManager::Get(); EffectManager & em = EffectManager::Get(); { for (auto &plug : pm.PluginsOfType(PluginTypeEffect|PluginTypeAudacityCommand)) { auto command = em.GetCommandIdentifier(plug.GetID()); if (!command.empty()) commands.push_back( { { command, plug.GetSymbol().Msgid() }, plug.GetPluginType() == PluginTypeEffect ? XO("Effect") : XO("Menu Command (With Parameters)") } ); } } auto &manager = CommandManager::Get( *project ); TranslatableStrings mLabels; CommandIDs mNames; std::vector vExcludeFromMacros; mLabels.clear(); mNames.clear(); manager.GetAllCommandLabels(mLabels, vExcludeFromMacros, true); manager.GetAllCommandNames(mNames, true); const bool english = wxGetLocale()->GetCanonicalName().StartsWith(wxT("en")); for(size_t i=0; i Entries::const_iterator { const auto less = [](const Entry &entryA, const Entry &entryB) { return entryA.name.StrippedTranslation() < entryB.name.StrippedTranslation(); }; auto range = std::equal_range( begin(), end(), Entry{ { {}, friendlyName }, {} }, less ); if (range.first != range.second) { wxASSERT_MSG( range.first + 1 == range.second, "Non-unique user-visible command name" ); return range.first; } else return end(); } // linear search auto MacroCommandsCatalog::ByCommandId( const CommandID &commandId ) const -> Entries::const_iterator { // Maybe this too should have a uniqueness check? return std::find_if( begin(), end(), [&](const Entry &entry) { return entry.name.Internal() == commandId; }); } wxString MacroCommands::GetCurrentParamsFor(const CommandID & command) { const PluginID & ID = EffectManager::Get().GetEffectByIdentifier(command); if (ID.empty()) { return wxEmptyString; // effect not found. } return EffectManager::Get().GetEffectParameters(ID); } wxString MacroCommands::PromptForParamsFor( const CommandID & command, const wxString & params, wxWindow &parent) { const PluginID & ID = EffectManager::Get().GetEffectByIdentifier(command); if (ID.empty()) { return wxEmptyString; // effect not found } wxString res = params; auto cleanup = EffectManager::Get().SetBatchProcessing(ID); if (EffectManager::Get().SetEffectParameters(ID, params)) { if (EffectManager::Get().PromptUser(ID, EffectUI::DialogFactory, parent)) { res = EffectManager::Get().GetEffectParameters(ID); } } return res; } wxString MacroCommands::PromptForPresetFor(const CommandID & command, const wxString & params, wxWindow *parent) { const PluginID & ID = EffectManager::Get().GetEffectByIdentifier(command); if (ID.empty()) { return wxEmptyString; // effect not found. } wxString preset = EffectManager::Get().GetPreset(ID, params, parent); // Preset will be empty if the user cancelled the dialog, so return the original // parameter value. if (preset.empty()) { return params; } return preset; } /// DoAudacityCommand() takes a PluginID and executes the associated command. /// /// At the moment flags are used only to indicate whether to prompt for /// parameters bool MacroCommands::DoAudacityCommand( const PluginID & ID, const CommandContext & context, unsigned flags ) { auto &project = context.project; auto &window = ProjectWindow::Get( project ); const PluginDescriptor *plug = PluginManager::Get().GetPlugin(ID); if (!plug) return false; if (flags & EffectManager::kConfigured) { ProjectAudioManager::Get( project ).Stop(); // SelectAllIfNone(); } EffectManager & em = EffectManager::Get(); bool success = em.DoAudacityCommand(ID, context, &window, (flags & EffectManager::kConfigured) == 0); if (!success) return false; /* if (em.GetSkipStateFlag()) flags = flags | OnEffectFlags::kSkipState; if (!(flags & OnEffectFlags::kSkipState)) { wxString shortDesc = em.GetCommandName(ID); wxString longDesc = em.GetCommandDescription(ID); PushState(longDesc, shortDesc); } */ window.RedrawProject(); return true; } bool MacroCommands::ApplyEffectCommand( const PluginID & ID, const TranslatableString &friendlyCommand, const CommandID & command, const wxString & params, const CommandContext & Context) { static_cast(command);//compiler food. //Possibly end processing here, if in batch-debug if( ReportAndSkip(friendlyCommand, params)) return true; const PluginDescriptor *plug = PluginManager::Get().GetPlugin(ID); if (!plug) return false; AudacityProject *project = &mProject; // IF nothing selected, THEN select everything depending // on preferences setting. // (most effects require that you have something selected). if( plug->GetPluginType() != PluginTypeAudacityCommand ) { if( !SelectUtilities::SelectAllIfNoneAndAllowed( *project ) ) { AudacityMessageBox( // i18n-hint: %s will be replaced by the name of an action, such as "Remove Tracks". XO("\"%s\" requires one or more tracks to be selected.").Format(friendlyCommand)); return false; } } bool res = false; auto cleanup = EffectManager::Get().SetBatchProcessing(ID); // transfer the parameters to the effect... if (EffectManager::Get().SetEffectParameters(ID, params)) { if( plug->GetPluginType() == PluginTypeAudacityCommand ) // and apply the effect... res = DoAudacityCommand(ID, Context, EffectManager::kConfigured | EffectManager::kSkipState | EffectManager::kDontRepeatLast); else // and apply the effect... res = EffectUI::DoEffect(ID, Context, EffectManager::kConfigured | EffectManager::kSkipState | EffectManager::kDontRepeatLast); } return res; } bool MacroCommands::HandleTextualCommand( CommandManager &commandManager, const CommandID & Str, const CommandContext & context, CommandFlag flags, bool alwaysEnabled) { switch ( commandManager.HandleTextualCommand( Str, context, flags, alwaysEnabled) ) { case CommandManager::CommandSuccess: return true; case CommandManager::CommandFailure: return false; case CommandManager::CommandNotFound: default: break; } // Not one of the singleton commands. // We could/should try all the list-style commands. // instead we only try the effects. EffectManager & em = EffectManager::Get(); for (auto &plug : PluginManager::Get().PluginsOfType(PluginTypeEffect)) if (em.GetCommandIdentifier(plug.GetID()) == Str) return EffectUI::DoEffect( plug.GetID(), context, EffectManager::kConfigured); return false; } bool MacroCommands::ApplyCommand( const TranslatableString &friendlyCommand, const CommandID & command, const wxString & params, CommandContext const * pContext) { // Test for an effect. const PluginID & ID = EffectManager::Get().GetEffectByIdentifier( command ); if (!ID.empty()) { if( pContext ) return ApplyEffectCommand( ID, friendlyCommand, command, params, *pContext); const CommandContext context( mProject ); return ApplyEffectCommand( ID, friendlyCommand, command, params, context); } AudacityProject *project = &mProject; auto &manager = CommandManager::Get( *project ); if( pContext ){ if( HandleTextualCommand( manager, command, *pContext, AlwaysEnabledFlag, true ) ) return true; pContext->Status( wxString::Format( _("Your batch command of %s was not recognized."), friendlyCommand.Translation() )); return false; } else { const CommandContext context( mProject ); if( HandleTextualCommand( manager, command, context, AlwaysEnabledFlag, true ) ) return true; } AudacityMessageBox( XO("Your batch command of %s was not recognized.") .Format( friendlyCommand ) ); return false; } bool MacroCommands::ApplyCommandInBatchMode( const TranslatableString &friendlyCommand, const CommandID & command, const wxString ¶ms, CommandContext const * pContext) { AudacityProject *project = &mProject; auto &settings = ProjectSettings::Get( *project ); // Recalc flags and enable items that may have become enabled. MenuManager::Get(*project).UpdateMenus(false); // enter batch mode... bool prevShowMode = settings.GetShowId3Dialog(); project->mBatchMode++; auto cleanup = finally( [&] { // exit batch mode... settings.SetShowId3Dialog(prevShowMode); project->mBatchMode--; } ); return ApplyCommand( friendlyCommand, command, params, pContext ); } static int MacroReentryCount = 0; // ApplyMacro returns true on success, false otherwise. // Any error reporting to the user in setting up the macro // has already been done. bool MacroCommands::ApplyMacro( const MacroCommandsCatalog &catalog, const wxString & filename) { // Check for reentrant ApplyMacro commands. // We'll allow 1 level of reentry, but not more. // And we treat ignoring deeper levels as a success. if (MacroReentryCount > 1) { return true; } // Restore the reentry counter (to zero) when we exit. auto cleanup1 = valueRestorer(MacroReentryCount); MacroReentryCount++; AudacityProject *proj = &mProject; bool res = false; // Only perform this group on initial entry. They should not be done // while recursing. if (MacroReentryCount == 1) { mFileName = filename; TranslatableString longDesc, shortDesc; wxString name = gPrefs->Read(wxT("/Batch/ActiveMacro"), wxEmptyString); if (name.empty()) { /* i18n-hint: active verb in past tense */ longDesc = XO("Applied Macro"); shortDesc = XO("Apply Macro"); } else { /* i18n-hint: active verb in past tense */ longDesc = XO("Applied Macro '%s'").Format(name); shortDesc = XO("Apply '%s'").Format(name); } // Save the project state before making any changes. It will be rolled // back if an error occurs. // It also causes any calls to ModifyState (such as by simple // view-changing commands) to append changes to this state, not to the // previous state in history. See Bug 2076 if (proj) { ProjectHistory::Get(*proj).PushState(longDesc, shortDesc); } } // Upon exit of the top level apply, roll back the state if an error occurs. auto cleanup2 = finally([&, macroReentryCount = MacroReentryCount] { if (macroReentryCount == 1 && !res && proj) { // Be sure that exceptions do not escape this destructor GuardedCall([&]{ // Macro failed or was cancelled; revert to the previous state auto &history = ProjectHistory::Get(*proj); history.RollbackState(); // The added undo state is now vacuous. Remove it (Bug 2759) auto &undoManager = UndoManager::Get(*proj); undoManager.Undo( [&]( const UndoStackElem &elem ){ history.PopState( elem.state ); } ); undoManager.AbandonRedo(); }); } }); mAbort = false; // Is tracing enabled? bool trace; gPrefs->Read(wxT("/EnableMacroTracing"), &trace, false); // If so, then block most other messages while running the macro wxLogLevel prevLevel = wxLog::GetComponentLevel(""); if (trace) { wxLog::SetComponentLevel("", wxLOG_FatalError); wxLog::SetComponentLevel(wxLOG_COMPONENT, wxLOG_Info); } size_t i = 0; for (; i < mCommandMacro.size(); i++) { const auto &command = mCommandMacro[i]; auto iter = catalog.ByCommandId(command); const auto friendly = (iter == catalog.end()) ? // uh oh, using GET to expose an internal name to the user! // in default of any better friendly name Verbatim( command.GET() ) : iter->name.Msgid().Stripped(); wxTimeSpan before; if (trace) { before = wxTimeSpan(0, 0, 0, wxGetUTCTimeMillis()); } bool success = ApplyCommandInBatchMode(friendly, command, mParamsMacro[i]); if (trace) { auto after = wxTimeSpan(0, 0, 0, wxGetUTCTimeMillis()); wxLogMessage(wxT("Macro line #%ld took %s : %s:%s"), i + 1, (after - before).Format(wxT("%H:%M:%S.%l")), command.GET(), mParamsMacro[i]); } if (!success || mAbort) break; } // Restore message level if (trace) { wxLog::SetComponentLevel("", prevLevel); } res = (i == mCommandMacro.size()); if (!res) return false; if (MacroReentryCount == 1) { mFileName.Empty(); if (proj) ProjectHistory::Get(*proj).ModifyState(true); } return true; } // AbortBatch() allows a premature terminatation of a batch. void MacroCommands::AbortBatch() { mAbort = true; } void MacroCommands::AddToMacro(const CommandID &command, int before) { AddToMacro(command, GetCurrentParamsFor(command), before); } void MacroCommands::AddToMacro(const CommandID &command, const wxString ¶ms, int before) { if (before == -1) { before = (int)mCommandMacro.size(); } mCommandMacro.insert(mCommandMacro.begin() + before, command); mParamsMacro.insert(mParamsMacro.begin() + before, params); } void MacroCommands::DeleteFromMacro(int index) { if (index < 0 || index >= (int)mCommandMacro.size()) { return; } mCommandMacro.erase( mCommandMacro.begin() + index ); mParamsMacro.erase( mParamsMacro.begin() + index ); } void MacroCommands::ResetMacro() { mCommandMacro.clear(); mParamsMacro.clear(); } // ReportAndSkip() is a diagnostic function that avoids actually // applying the requested effect if in batch-debug mode. bool MacroCommands::ReportAndSkip( const TranslatableString & friendlyCommand, const wxString & params) { int bDebug; gPrefs->Read(wxT("/Batch/Debug"), &bDebug, false); if( bDebug == 0 ) return false; //TODO: Add a cancel button to these, and add the logic so that we can abort. if( !params.empty() ) { AudacityMessageBox( XO("Apply %s with parameter(s)\n\n%s") .Format( friendlyCommand, params ), XO("Test Mode")); } else { AudacityMessageBox( XO("Apply %s").Format( friendlyCommand ), XO("Test Mode")); } return true; } void MacroCommands::MigrateLegacyChains() { static bool done = false; if (!done) { // Check once per session at most // Copy chain files from the old Chains into the new Macros directory, // but only if like-named files are not already present in Macros. // Leave the old copies in place, in case a user wants to go back to // an old Audacity version. They will have their old chains intact, but // won't have any edits they made to the copy that now lives in Macros // which old Audacity will not read. const auto oldDir = FileNames::LegacyChainDir(); FilePaths files; wxDir::GetAllFiles(oldDir, &files, wxT("*.txt"), wxDIR_FILES); // add a dummy path component to be overwritten by SetFullName wxFileName newDir{ FileNames::MacroDir(), wxT("x") }; for (const auto &file : files) { auto name = wxFileName{file}.GetFullName(); newDir.SetFullName(name); const auto newPath = newDir.GetFullPath(); if (!wxFileExists(newPath)) FileNames::DoCopyFile(file, newPath); } done = true; } // To do: use std::once } wxArrayString MacroCommands::GetNames() { MigrateLegacyChains(); wxArrayString names; FilePaths files; wxDir::GetAllFiles(FileNames::MacroDir(), &files, wxT("*.txt"), wxDIR_FILES); size_t i; wxFileName ff; for (i = 0; i < files.size(); i++) { ff = (files[i]); names.push_back(ff.GetName()); } std::sort( names.begin(), names.end() ); return names; } bool MacroCommands::IsFixed(const wxString & name) { auto defaults = GetNamesOfDefaultMacros(); if( make_iterator_range( defaults ).contains( name ) ) return true; return false; } void MacroCommands::Split(const wxString & str, wxString & command, wxString & param) { int splitAt; command.Empty(); param.Empty(); if (str.empty()) { return; } splitAt = str.Find(wxT(':')); if (splitAt < 0) { return; } command = str.Mid(0, splitAt); param = str.Mid(splitAt + 1); return; } wxString MacroCommands::Join(const wxString & command, const wxString & param) { return command + wxT(": ") + param; }