diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index dd1dbe3..0000000 --- a/.editorconfig +++ /dev/null @@ -1,210 +0,0 @@ -# editorconfig.org - -# top-most EditorConfig file -root = true - -# Default settings: -# A newline ending every file -# Use 4 spaces as indentation -[*] -insert_final_newline = true -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true - -[project.json] -indent_size = 2 - -# C# and Visual Basic files -[*.{cs,vb}] -charset = utf-8-bom - -# Analyzers -dotnet_analyzer_diagnostic.category-Security.severity = error -dotnet_code_quality.ca1802.api_surface = private, internal - -# Miscellaneous style rules -dotnet_sort_system_directives_first = true -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion - -# avoid this. unless absolutely necessary -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion - -# name all constant fields using PascalCase -dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style -dotnet_naming_symbols.constant_fields.applicable_kinds = field -dotnet_naming_symbols.constant_fields.required_modifiers = const -dotnet_naming_style.pascal_case_style.capitalization = pascal_case - -# static fields should have s_ prefix -dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion -dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields -dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style -dotnet_naming_symbols.static_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.required_modifiers = static -dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected -dotnet_naming_style.static_prefix_style.required_prefix = s_ -dotnet_naming_style.static_prefix_style.capitalization = camel_case - -# internal and private fields should be _camelCase -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case - -# Code quality -dotnet_style_readonly_field = true:suggestion -dotnet_code_quality_unused_parameters = non_public:suggestion - -# Expression-level preferences -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:refactoring -dotnet_style_prefer_conditional_expression_over_return = true:refactoring - -# CA2208: Instantiate argument exceptions correctly -dotnet_diagnostic.CA2208.severity = error - -# C# files -[*.cs] -# New line preferences -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true - -# Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true -csharp_indent_switch_labels = true -csharp_indent_labels = one_less_than_current - -# Modifier preferences -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion - -# Code style defaults -csharp_using_directive_placement = outside_namespace:suggestion -csharp_prefer_braces = true:refactoring -csharp_preserve_single_line_blocks = true:none -csharp_preserve_single_line_statements = false:none -csharp_prefer_static_local_function = true:suggestion -csharp_prefer_simple_using_statement = false:none -csharp_style_prefer_switch_expression = true:suggestion - -# Expression-bodied members -csharp_style_expression_bodied_methods = true:refactoring -csharp_style_expression_bodied_constructors = true:refactoring -csharp_style_expression_bodied_operators = true:refactoring -csharp_style_expression_bodied_properties = true:refactoring -csharp_style_expression_bodied_indexers = true:refactoring -csharp_style_expression_bodied_accessors = true:refactoring -csharp_style_expression_bodied_lambdas = true:refactoring -csharp_style_expression_bodied_local_functions = true:refactoring - -# Pattern matching -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion - -# Null checking preferences -csharp_style_throw_expression = true:suggestion -csharp_style_conditional_delegate_call = true:suggestion - -# Other features -csharp_style_prefer_index_operator = false:none -csharp_style_prefer_range_operator = false:none -csharp_style_pattern_local_over_anonymous_function = false:none - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = do_not_ignore -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_between_square_brackets = false - -# Namespace preference -csharp_style_namespace_declarations = file_scoped:suggestion - -# Types: use keywords instead of BCL types, and permit var only when the type is clear -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = false:none -csharp_style_var_elsewhere = false:suggestion - -# Visual Basic files -[*.vb] -# Modifier preferences -visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion - -# C++ Files -[*.{cpp,h,in}] -curly_bracket_next_line = true -indent_brace_style = Allman - -# Xml project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] -indent_size = 2 - -# Xml build files -[*.builds] -indent_size = 2 - -# Xml files -[*.{xml,stylecop,resx,ruleset}] -indent_size = 2 - -# Xml config files -[*.{props,targets,config,nuspec}] -indent_size = 2 - -# Shell scripts -[*.sh] -end_of_line = lf - -[*.{cmd, bat}] -end_of_line = crlf - -# Markdown files -[*.md] - # Double trailing spaces can be used for BR tags, and other instances are enforced by Markdownlint -trim_trailing_whitespace = false \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5aec640..8b9b1a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,21 +11,28 @@ on: jobs: build: + name: ${{ matrix.target }} + strategy: matrix: - os: [windows-latest, macos-latest] + include: + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: x86_64-apple-darwin + os: macos-13 + - target: aarch64-apple-darwin + os: macos-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - uses: dtolnay/rust-toolchain@master with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore + toolchain: stable + target: ${{ matrix.target }} - name: Build - run: dotnet build --no-restore + run: + cargo build --all --target ${{ matrix.target }} - name: Test - run: dotnet test --no-build --verbosity normal + run: cargo test --all --target ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4d4ec6..40f5e2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,3 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - name: Release on: @@ -9,32 +6,48 @@ on: jobs: release: + name: ${{ matrix.target }} + permissions: contents: write + + strategy: + matrix: + include: + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: x86_64-apple-darwin + os: macos-13 + - target: aarch64-apple-darwin + os: macos-latest - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - uses: dtolnay/rust-toolchain@master with: - dotnet-version: 8.0.x - - name: Publish + toolchain: stable + target: ${{ matrix.target }} + - name: Build run: | - dotnet publish src -r win-x64 - dotnet publish src -r osx-x64 - dotnet publish src -r osx-arm64 + cargo build --release --target ${{ matrix.target }} + + if [ "${{ matrix.os }}" = "windows-latest" ]; then + bin="target/${{ matrix.target }}/release/livetagger.exe" + else + bin="target/${{ matrix.target }}/release/livetagger" + fi + + echo "BIN=$bin" >> $GITHUB_ENV - name: Zip artifacts run: | tag=$(git describe --tags --abbrev=0) - 7z a "livetagger-$tag-win-x64.zip" "./src/bin/Release/net8.0/win-x64/publish/*" - 7z a "livetagger-$tag-osx-x64.zip" "./src/bin/Release/net8.0/osx-x64/publish/*" - 7z a "livetagger-$tag-osx-arm64.zip" "./src/bin/Release/net8.0/osx-arm64/publish/*" + 7z a "livetagger-$tag-$target-x64.zip" $bin - name: Publish uses: softprops/action-gh-release@v2 with: files: "livetagger*" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 104b544..0104787 100644 --- a/.gitignore +++ b/.gitignore @@ -1,484 +1,17 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from `dotnet new gitignore` +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ -# dotenv files -.env +# These are backup files generated by rustfmt +**/*.rs.bk -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch +# MSVC Windows builds of rustc generate these, which store debugging information *.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ - -# Mac bundle stuff -*.dmg -*.app - -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk -# Vim temporary swap files -*.swp +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8dc5c45 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,639 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "cc" +version = "1.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "livemeta" +version = "0.1.0" +dependencies = [ + "thiserror", + "xmp_toolkit", +] + +[[package]] +name = "livetagger" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "glob", + "livemeta", + "pretty_assertions", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" + +[[package]] +name = "toml_edit" +version = "0.22.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "xmp_toolkit" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2064f77c8bc1b0a1513ce64a6904d9c300c904b65cec4b237322039bab3c477" +dependencies = [ + "cc", + "fs_extra", + "num_enum", + "thiserror", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..81bcd21 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "livetagger" +version = "0.1.0" +edition = "2024" + +[workspace] +resolver = "3" +members = ["livemeta"] + +[dependencies] +livemeta = { path = "livemeta" } + +anyhow = "1.0.98" +clap = { version = "4.5.37", features = ["derive"] } +glob = "0.3.2" +tracing = "0.1.41" +tracing-subscriber = "0.3.19" + +[dev-dependencies] +pretty_assertions = "1.4.1" + +[profile.release] +strip = true \ No newline at end of file diff --git a/LiveTagger.sln b/LiveTagger.sln deleted file mode 100644 index f29e194..0000000 --- a/LiveTagger.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveTagger", "src\LiveTagger.csproj", "{4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveTaggerTests", "test\LiveTaggerTests.csproj", "{901323E9-A7B8-419D-907A-AC96C79103F7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4971F5F4-485C-4CF6-8C0F-E0E86C6938C7}.Release|Any CPU.Build.0 = Release|Any CPU - {901323E9-A7B8-419D-907A-AC96C79103F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {901323E9-A7B8-419D-907A-AC96C79103F7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {901323E9-A7B8-419D-907A-AC96C79103F7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {901323E9-A7B8-419D-907A-AC96C79103F7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {7658FD62-06F3-4E35-9D2E-0CD58490507A} - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md index 713e5f8..d9cecf2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It allows you to quickly automate tasks that would be tedious to do via Live's U To download LiveTagger, go to the [releases page](https://github.com/17cupsofcoffee/LiveTagger/releases) and download the .zip file for your platform. Extract this into a location that is on your system `PATH`. -Alternatively, if you want to build from source, clone this repo and run `dotnet publish src`. +Alternatively, if you want to build from source, clone this repo and run `cargo build`. ## Usage @@ -31,6 +31,8 @@ This command has various options you can use to tweak the behaviour: * `--commit` (or `-c`) makes the command save its changes. * Without this, the command will just log what files would be impacted. * **I strongly suggest running without `--commit` before making any big changes, to make sure the command is going to do what you're expecting!** +* `--backup` (or `-b`) will create a backup of any files that are changed. + * To restore a backup, go to the `Ableton Folder Info` subdirectory next to the files you tagged, and rename the `.xmp.bak` file to `.xmp`. There are also several other commands available: @@ -47,5 +49,4 @@ If you specify a category/tag/subtag that does not exist, Live will create it au ## Notes -* This tool works by manually modifying the XMP metadata files that Live creates. If Ableton change the format of those files, this tool will probably break! -* Whenever LiveTagger makes a change to the metadata, it will write a backup first. If you need to revert the changes, go to the `Ableton Folder Info` subfolder of the folder you ran the tool on, and replace the `.xmp` file with the backup. Later versions may add commands for this. +* This tool works by manually modifying the XMP metadata files that Live creates. If Ableton change the format of those files, this tool may break! diff --git a/livemeta/Cargo.toml b/livemeta/Cargo.toml new file mode 100644 index 0000000..b346a77 --- /dev/null +++ b/livemeta/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "livemeta" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = "2.0.12" +xmp_toolkit = "1.9.2" diff --git a/livemeta/src/error.rs b/livemeta/src/error.rs new file mode 100644 index 0000000..a99cc61 --- /dev/null +++ b/livemeta/src/error.rs @@ -0,0 +1,16 @@ +use thiserror::Error; +use xmp_toolkit::XmpError; + +#[derive(Error, Debug)] +pub enum Error { + #[error("io error")] + Io(#[from] std::io::Error), + + #[error("xmp error")] + Xmp(#[from] XmpError), + + #[error("missing field: {0}")] + MissingField(&'static str), +} + +pub type Result = std::result::Result; diff --git a/livemeta/src/folder.rs b/livemeta/src/folder.rs new file mode 100644 index 0000000..fd806df --- /dev/null +++ b/livemeta/src/folder.rs @@ -0,0 +1,234 @@ +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; + +use xmp_toolkit::{FromStrOptions, ToStringOptions, XmpDateTime, XmpMeta, XmpValue, xmp_ns}; + +use crate::error::{Error, Result}; + +const ABLETON_NS: &str = "https://ns.ableton.com/xmp/fs-resources/1.0/"; + +static METADATA_TEMPLATE: &str = r#" + + + + application/vnd.ableton.folder + folder + + + + + +"#; + +/// Returns the path to a given folder's Ableton Live metadata. +pub fn get_folder_metadata_path(folder: &Path) -> PathBuf { + folder.join("Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp") +} + +/// Returns whether or not a path points at Ableton Live folder metadata. +/// +/// This includes the 'Ableton Folder Info' subdirectory itself, as well as +/// any files contained within it. +pub fn is_folder_metadata(path: &Path) -> bool { + path.iter() + .filter_map(OsStr::to_str) + .any(|component| component == "Ableton Folder Info") +} + +/// Ableton Live metadata for a folder. +#[derive(Debug)] +pub struct FolderMetadata { + xmp: XmpMeta, + dirty: bool, +} + +impl FolderMetadata { + /// Create an empty document. + pub fn new() -> Result { + Self::from_xmp_str(METADATA_TEMPLATE) + } + + /// Reads a document from a `&str`. + pub fn from_xmp_str(data: &str) -> Result { + Ok(FolderMetadata { + xmp: XmpMeta::from_str_with_options(data, FromStrOptions::default())?, + dirty: false, + }) + } + + /// Reads a document from a `.xmp` file. + pub fn from_xmp_file(path: &Path) -> Result { + let data = fs::read_to_string(path)?; + + Self::from_xmp_str(&data) + } + + /// Returns whether the document has changed since it was loaded. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Outputs the document as XML. + pub fn to_xml(&self) -> Result { + let xml = self.xmp.to_string_with_options( + ToStringOptions::default() + .omit_packet_wrapper() + .set_indent_string(" ".into()), + )?; + + Ok(xml) + } + + /// Sets the 'CreatorTool' property on the document. + pub fn set_creator_tool(&mut self, value: impl Into) -> Result { + self.xmp + .set_property(xmp_ns::XMP, "CreatorTool", &XmpValue::new(value.into()))?; + + self.dirty = true; + + Ok(()) + } + + /// Sets the 'CreateDate' property on the document to the current date and time. + pub fn update_create_date(&mut self) -> Result { + self.xmp.set_property_date( + xmp_ns::XMP, + "CreateDate", + &XmpValue::new(XmpDateTime::current()?), + )?; + + self.dirty = true; + + Ok(()) + } + + /// Sets the 'MetadataDate' property on the document to the current date and time. + pub fn update_metadata_date(&mut self) -> Result { + self.xmp.set_property_date( + xmp_ns::XMP, + "MetadataDate", + &XmpValue::new(XmpDateTime::current()?), + )?; + + self.dirty = true; + + Ok(()) + } + + /// Returns the number of items (aka tagged files) in the document. + pub fn item_count(&self) -> usize { + self.xmp.array_len(ABLETON_NS, "items") + } + + /// Reads the filename of an item in the document. + pub fn get_filename(&self, item: &ItemSelector) -> Result { + self.xmp + .property(ABLETON_NS, &item.filename) + .map(|v| v.value) + .ok_or(Error::MissingField("")) + } + + /// Sets the filename of an item in the document. + pub fn set_filename(&mut self, item: &ItemSelector, value: impl Into) -> Result { + self.xmp + .set_property(ABLETON_NS, &item.filename, &XmpValue::new(value.into()))?; + + self.dirty = true; + + Ok(()) + } + + /// Returns the number of keywords (aka tags) for an item in the document. + pub fn keyword_count(&self, item: &ItemSelector) -> usize { + self.xmp.array_len(ABLETON_NS, &item.keywords.value) + } + + /// Reads a keyword from an item in the document. + pub fn get_keyword(&self, item: &ItemSelector, i: usize) -> Result { + self.xmp + .array_item(ABLETON_NS, &item.keywords.value, i as i32) + .map(|v| v.value) + .ok_or(Error::MissingField("")) + } + + /// Adds a keyword to an item in the document. + pub fn push_keyword(&mut self, item: &ItemSelector, value: impl Into) -> Result { + self.xmp + .append_array_item(ABLETON_NS, &item.keywords, &XmpValue::new(value.into()))?; + + self.dirty = true; + + Ok(()) + } + + /// Deletes a keyword from an item in the document. + /// + /// This will not remove the item itself, even if all the keywords are gone - + /// while keywords are currently the only metadata stored for each file, + /// Ableton could potentially add additional data in future versions. + pub fn delete_keyword(&mut self, item: &ItemSelector, i: usize) -> Result { + self.xmp + .delete_array_item(ABLETON_NS, &item.keywords.value, i as i32)?; + + self.dirty = true; + + Ok(()) + } + + /// Deletes all keywords from an item in the document. + /// + /// This will not remove the item itself - while keywords are currently the + /// only metadata stored for each file, Ableton could potentially add + /// additional data in future versions. + pub fn delete_keywords(&mut self, item: &ItemSelector) -> Result { + self.xmp.delete_property(ABLETON_NS, &item.keywords.value)?; + + self.dirty = true; + + Ok(()) + } +} + +/// Paths for an individual item in a metadata document. +pub struct ItemSelector { + filename: String, + keywords: XmpValue, +} + +impl ItemSelector { + /// Creates a new item selector for an item in a documents. + /// + /// This does not validate that the item actually exists! + pub fn new(i: usize) -> Result { + let item_path = XmpMeta::compose_array_item_path(ABLETON_NS, "items", i as i32)?; + + let filename = + XmpMeta::compose_struct_field_path(ABLETON_NS, &item_path, ABLETON_NS, "filePath")?; + + let keywords = + XmpMeta::compose_struct_field_path(ABLETON_NS, &item_path, ABLETON_NS, "keywords")?; + + Ok(ItemSelector { + filename, + keywords: XmpValue::new(keywords).set_is_array(true), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_detect_folder_metadata() { + assert!(is_folder_metadata(Path::new( + "C:/foo/Ableton Folder Info/file.txt" + ))); + } + + #[test] + fn should_ignore_non_metadata_file() { + assert!(!is_folder_metadata(Path::new("C:/foo/sound.wav"))); + } +} diff --git a/livemeta/src/lib.rs b/livemeta/src/lib.rs new file mode 100644 index 0000000..460f526 --- /dev/null +++ b/livemeta/src/lib.rs @@ -0,0 +1,37 @@ +mod error; +mod folder; +mod sample; + +pub use error::*; +pub use folder::*; +pub use sample::*; + +use std::path::Path; + +/// Returns whether or not a path points at Ableton Live metadata. +pub fn is_metadata(path: &Path) -> bool { + is_sample_metadata(path) || is_folder_metadata(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_detect_folder_metadata() { + assert!(is_metadata(Path::new( + "C:/foo/Ableton Folder Info/file.txt" + ))); + } + + #[test] + fn should_detect_sample_metadata() { + assert!(is_metadata(Path::new("C:/foo/metadata.asd"))); + assert!(is_metadata(Path::new("C:/foo/metadata.ASD"))); + } + + #[test] + fn should_ignore_non_metadata_file() { + assert!(!is_metadata(Path::new("C:/foo/sound.wav"))); + } +} diff --git a/livemeta/src/sample.rs b/livemeta/src/sample.rs new file mode 100644 index 0000000..cec4ccc --- /dev/null +++ b/livemeta/src/sample.rs @@ -0,0 +1,57 @@ +use std::ffi::OsStr; +use std::path::Path; + +static SUPPORTED_EXTS: &[&str] = &[ + "wav", "wave", "aif", "aiff", "flac", "ogg", "mp3", "mp4", "m4a", +]; + +/// Returns whether or not a path points at an Ableton Live sample analysis file. +pub fn is_sample_metadata(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .filter(|ext| ext.eq_ignore_ascii_case("asd")) + .is_some() +} + +/// Returns whether a path's extension matches any of Ableton Live's supported +/// sample formats. +/// +/// See . +pub fn is_supported_sample_format(path: &Path) -> bool { + let Some(ext) = path.extension().and_then(OsStr::to_str) else { + return false; + }; + + SUPPORTED_EXTS + .iter() + .any(|supported| supported.eq_ignore_ascii_case(ext)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_detect_sample_files() { + let samples = [ + "sound.wav", + "sound.wave", + "sound.aif", + "sound.aiff", + "sound.flac", + "sound.ogg", + "sound.mp3", + "sound.mp4", + "sound.m4a", + ]; + + for sample in samples { + assert!(is_supported_sample_format(Path::new(sample))); + assert!(is_supported_sample_format(Path::new( + &sample.to_uppercase() + ))); + } + + assert!(!is_supported_sample_format(Path::new("sound.exe"))); + } +} diff --git a/src/AbletonMetadata.cs b/src/AbletonMetadata.cs deleted file mode 100644 index 79ea536..0000000 --- a/src/AbletonMetadata.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace LiveTagger; - -/// -/// Helper functions for dealing with Ableton metadata files. -/// -public static class AbletonMetadata -{ - /// - /// The list of supported file extensions. - /// See https://help.ableton.com/hc/en-us/articles/211427589-Supported-Audio-File-Formats. - /// - private static readonly List s_supportedExtensions = [ - ".wav", - ".wave", - ".aif", - ".aiff", - ".flac", - ".ogg", - ".mp3", - ".mp4", - ".m4a" - ]; - - /// - /// Returns whether or not a path represents some kind of Ableton metadata. - /// - /// This is not comprehensive - it's mainly just used to exclude files from - /// the tagging process. - /// - /// The path to check. - /// Whether or not the path is for metadata. - public static bool IsMetadata(string path) - { - return path.Contains("Ableton Folder Info") || Path.GetExtension(path).Equals(".asd", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Returns the XMP metadata path for a given folder. - /// - /// The folder to find the metadata path for. - /// The metadata path. - public static string GetXmpFilePath(string folder) - { - return Path.Join(folder, "Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp"); - } - - /// - /// Checks if the specified filename has a file extension that Live supports. - /// - /// The filename to check. - /// Whether or not the file is supported by Live. - public static bool IsSupportedSampleFormat(string filename) - { - return s_supportedExtensions.Contains(Path.GetExtension(filename).ToLower()); - } -} diff --git a/src/LiveTagger.csproj b/src/LiveTagger.csproj deleted file mode 100644 index 95e65f6..0000000 --- a/src/LiveTagger.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - Exe - net8.0 - enable - enable - true - true - true - livetagger - true - - - - - - - - diff --git a/src/Program.cs b/src/Program.cs deleted file mode 100644 index 04a50d3..0000000 --- a/src/Program.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.CommandLine; -using Ganss.IO; - -namespace LiveTagger; - -class Program -{ - static void Main(string[] args) - { - var rootCommand = new RootCommand("LiveTagger"); - - var tagsArg = new Argument>("tags", "The tags to apply to the matched files."); - var includeOption = new Option(["--include", "-i"], () => "*", "A glob pattern specifying which files should be processed."); - var commitOption = new Option(["--commit", "-c"], "Saves changes to the filesystem. Run without this first, to make sure you're tagging the correct files!"); - - var addTagCommand = new Command("add", "Adds tags to a set of files."); - addTagCommand.AddArgument(tagsArg); - addTagCommand.AddOption(includeOption); - addTagCommand.AddOption(commitOption); - - addTagCommand.SetHandler(AddTags, includeOption, tagsArg, commitOption); - - rootCommand.AddCommand(addTagCommand); - - var removeTagCommand = new Command("remove", "Removes tags from a set of files."); - removeTagCommand.AddArgument(tagsArg); - removeTagCommand.AddOption(includeOption); - removeTagCommand.AddOption(commitOption); - - removeTagCommand.SetHandler(RemoveTags, includeOption, tagsArg, commitOption); - - rootCommand.AddCommand(removeTagCommand); - - var removeAllTagsCommand = new Command("remove-all", "Removes all tags from a set of files."); - removeAllTagsCommand.AddOption(includeOption); - removeAllTagsCommand.AddOption(commitOption); - - removeAllTagsCommand.SetHandler(RemoveAllTags, includeOption, commitOption); - - rootCommand.AddCommand(removeAllTagsCommand); - - rootCommand.Invoke(args); - } - - /// - /// Adds the specified tags to all files under the given parent directory. - /// - /// A glob pattern specifying which files should be processed. - /// The tags to add. - /// Whether changes should be saved. - private static void AddTags(string include, List tags, bool commit) - { - ProcessXmp(include, commit, (xmp, files) => xmp.AddTags(files, tags)); - } - - /// - /// Removes the specified tags from all files under the given parent directory. - /// - /// A glob pattern specifying which files should be processed. - /// The tags to remove. - /// Whether changes should be saved. - private static void RemoveTags(string include, List tags, bool commit) - { - ProcessXmp(include, commit, (xmp, files) => xmp.RemoveTags(files, tags)); - } - - /// - /// Removes all tags from all files under the given parent directory. - /// - /// A glob pattern specifying which files should be processed. - /// The tags to remove. - /// Whether changes should be saved. - private static void RemoveAllTags(string include, bool commit) - { - ProcessXmp(include, commit, (xmp, files) => xmp.RemoveTags(files)); - } - - /// - /// Run an action on the XMP files for a given directory. - /// - /// A glob pattern specifying which files should be processed. - /// Whether changes should be saved. - /// The action to run. - private static void ProcessXmp(string include, bool commit, Action> action) - { - var folders = SearchForFiles(include); - - foreach (var (folder, files) in folders) - { - Console.WriteLine($"Processing {folder}."); - - var xmpFilePath = AbletonMetadata.GetXmpFilePath(folder); - - Xmp xmp = Path.Exists(xmpFilePath) - ? Xmp.FromFile(xmpFilePath) - : new Xmp(); - - action(xmp, files); - - if (xmp.IsDirty) - { - if (commit) - { - xmp.Save(xmpFilePath); - } - - Console.WriteLine($"Metadata updated for {folder}."); - } - else - { - Console.WriteLine($"No changes required for {folder}."); - } - } - - if (commit) - { - Console.WriteLine("Done!"); - } - else - { - Console.WriteLine("Re-run with --commit to apply the above changes."); - } - } - - /// - /// Searches for files to process. - /// - /// A glob pattern specifying which files should be processed. - /// A mapping of directories to individual files. - private static Dictionary> SearchForFiles(string include) - { - var folders = new Dictionary>(); - - foreach (string file in Glob.ExpandNames(include)) - { - if (AbletonMetadata.IsMetadata(file) || Directory.Exists(file)) - { - continue; - } - - if (!AbletonMetadata.IsSupportedSampleFormat(file)) - { - Console.WriteLine($"Skipping {file} as it doesn't look like an audio file"); - continue; - } - - var parent = Directory.GetParent(file)!.FullName; - var filename = Path.GetFileName(file); - - if (folders.ContainsKey(parent)) - { - folders[parent].Add(filename); - } - else - { - folders.Add(parent, [filename]); - } - } - - return folders; - } - -} diff --git a/src/XmlConstants/Ableton.cs b/src/XmlConstants/Ableton.cs deleted file mode 100644 index aa68942..0000000 --- a/src/XmlConstants/Ableton.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Xml.Linq; - -namespace LiveTagger.XmlConstants; - -public static class Ableton -{ - public static readonly XNamespace Namespace = "https://ns.ableton.com/xmp/fs-resources/1.0/"; - - public static readonly XName Items = Namespace + "items"; - public static readonly XName FilePath = Namespace + "filePath"; - public static readonly XName Keywords = Namespace + "keywords"; -} diff --git a/src/XmlConstants/Rdf.cs b/src/XmlConstants/Rdf.cs deleted file mode 100644 index 8f51ca7..0000000 --- a/src/XmlConstants/Rdf.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Xml.Linq; - -namespace LiveTagger.XmlConstants; - -public static class Rdf -{ - public static readonly XNamespace Namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; - - public static readonly XName RdfWrapper = Namespace + "RDF"; - public static readonly XName Description = Namespace + "Description"; - public static readonly XName Bag = Namespace + "Bag"; - public static readonly XName Li = Namespace + "li"; - public static readonly XName ParseType = Namespace + "parseType"; -} \ No newline at end of file diff --git a/src/Xmp.cs b/src/Xmp.cs deleted file mode 100644 index ce9764e..0000000 --- a/src/Xmp.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System.Xml.Linq; -using LiveTagger.XmlConstants; - -namespace LiveTagger; - -public class Xmp -{ - /// - /// Base template for new XMP files. - /// - private static readonly string s_xmlTemplate = """ - - - - - application/vnd.ableton.folder - folder - - - - - - - """; - - /// - /// Whether any changes have been made to the document since the last save. - /// - public bool IsDirty { get; private set; } = false; - - /// - /// The in-memory XML document. - /// - public XElement Xml { get; private set; } - - /// - /// A pointer to the main content of the document. - /// - private XElement _itemsBag; - - /// - /// Creates a new XMP document. - /// - /// The XML to populate the document with. - private Xmp(XElement xml) - { - Xml = xml; - - _itemsBag = Xml.Element(Rdf.RdfWrapper)? - .Element(Rdf.Description)? - .Element(Ableton.Items)? - .Element(Rdf.Bag) - ?? throw new InvalidOperationException("Malformed XMP document"); - } - - /// - /// Creates a new XMP document, with no metadata. - /// - public Xmp() : this(XElement.Parse(s_xmlTemplate.Trim())) { } - - /// - /// Loads an XMP document from a string. - /// - /// The XML to load. - /// The loaded XMP document. - public static Xmp FromString(string xml) - { - return new Xmp(XElement.Parse(xml)); - } - - /// - /// Loads an XMP document from the filesystem. - /// - /// The path to the XMP file to load. - /// The loaded XMP document. - public static Xmp FromFile(string path) - { - return new Xmp(XElement.Load(path)); - } - - /// - /// Saves the XMP document to the filesystem, including any changes. - /// If the file hasn't actually been changed, this will have no effect. - /// If the file already exists, a backup will be made. - /// - /// The path to save the file to. - public void Save(string path) - { - if (Path.Exists(path)) - { - File.Copy(path, path + ".bak", true); - } - else - { - Directory.CreateDirectory(Path.GetDirectoryName(path)); - } - - Xml.Save(path); - - IsDirty = false; - } - - /// - /// Tag a file. - /// - /// The file to tag. - /// The tags to apply. - public void AddTags(string file, List tags) - { - var tagsAdded = new List(); - - var existingItem = GetFileItem(file); - - if (existingItem != null) - { - // Add keyword to existing items - var keywords = existingItem.Element(Ableton.Keywords); - var keywordBag = keywords.Element(Rdf.Bag); - - foreach (string tag in tags) - { - if (!keywordBag.Elements(Rdf.Li).Any(e => e.Value == tag)) - { - keywordBag.Add(new XElement(Rdf.Li, tag)); - tagsAdded.Add(tag); - IsDirty = true; - } - } - } - else - { - // Create new item - var newItem = new XElement(Rdf.Li, new XAttribute(Rdf.ParseType, "Resource"), - new XElement(Ableton.FilePath, file), - new XElement(Ableton.Keywords, - new XElement(Rdf.Bag, - tags.Select(tag => new XElement(Rdf.Li, tag)) - ) - ) - ); - - _itemsBag.Add(newItem); - tagsAdded = tags; - IsDirty = true; - } - - if (tagsAdded.Count > 0) - { - Console.WriteLine($"Added tags to {file}: {string.Join(", ", tagsAdded)}"); - } - } - - - /// - /// Tag a list of files. - /// - /// The files to tag. - /// The tags to apply. - public void AddTags(List files, List tags) - { - foreach (string file in files) - { - AddTags(file, tags); - } - } - - /// - /// Remove a specific set of tags from a file. - /// - /// The file to untag. - /// The tags to remove. - public void RemoveTags(string file, List tags) - { - var item = GetFileItem(file); - - if (item != null) - { - var keywords = item.Element(Ableton.Keywords); - var keywordBag = keywords.Element(Rdf.Bag); - - var tagsRemoved = new List(); - var elementsToRemove = new List(); - - foreach (XElement keyword in keywordBag.Elements(Rdf.Li)) - { - if (tags.Contains(keyword.Value)) - { - tagsRemoved.Add(keyword.Value); - elementsToRemove.Add(keyword); - } - } - - if (elementsToRemove.Count > 0) - { - if (elementsToRemove.Count == keywordBag.Elements(Rdf.Li).Count()) - { - keywords.Remove(); - } - else - { - elementsToRemove.Remove(); - } - - IsDirty = true; - Console.WriteLine($"Removed tags from {file}: {string.Join(", ", tagsRemoved)}"); - } - } - } - - /// - /// Remove a specific set of tags from a list of files. - /// - /// The files to untag. - /// The tags to remove. - public void RemoveTags(List files, List tags) - { - foreach (string file in files) - { - RemoveTags(file, tags); - } - } - - /// - /// Remove all tags from a file. - /// - /// The file to untag. - public void RemoveTags(string file) - { - var item = GetFileItem(file); - - if (item != null) - { - var keywords = item.Element(Ableton.Keywords); - - keywords.Remove(); - IsDirty = true; - Console.WriteLine($"Removed all tags from {file}"); - } - } - - /// - /// Remove all tags from a list of files. - /// - /// The files to untag. - public void RemoveTags(List files) - { - foreach (string file in files) - { - RemoveTags(file); - } - } - - /// - /// Gets the bag item for the given filename. - /// - /// - /// - private XElement? GetFileItem(string file) - { - foreach (var item in _itemsBag.Elements(Rdf.Li)) - { - var filePath = item.Element(Ableton.FilePath); - - if (filePath != null && filePath.Value == file) - { - return item; - } - } - - return null; - } -} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..1e407ac --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,212 @@ +use std::collections::HashSet; + +use livemeta::{FolderMetadata, ItemSelector}; +use tracing::info; + +/// Adds tags to the specified files. +/// +/// If an entry for a file does not exist yet in the metadata document, it will be added. +pub fn add_tags( + doc: &mut FolderMetadata, + mut files: HashSet, + tags: &[String], +) -> anyhow::Result<()> { + let item_count = doc.item_count(); + + for i in 1..=item_count { + let mut tags_added = Vec::new(); + + let item = ItemSelector::new(i)?; + + let filename = doc.get_filename(&item)?; + + if files.take(&filename).is_some() { + let keyword_count = doc.keyword_count(&item); + let mut keywords = HashSet::new(); + + for i in 1..=keyword_count { + keywords.insert(doc.get_keyword(&item, i)?); + } + + for tag in tags { + if !keywords.contains(tag) { + doc.push_keyword(&item, tag.clone())?; + tags_added.push(tag.as_str()); + } + } + + if !tags_added.is_empty() { + info!("Adding tags to {}: {}", filename, tags_added.join(", ")); + } + } + } + + for (i, new_file) in files.into_iter().enumerate() { + let mut tags_added = Vec::new(); + + let item = ItemSelector::new(item_count + i + 1)?; + + doc.set_filename(&item, new_file.clone())?; + + for tag in tags { + doc.push_keyword(&item, tag.clone())?; + tags_added.push(tag.as_str()); + } + + if !tags_added.is_empty() { + info!("Adding tags to {}: {}", &new_file, tags_added.join(", ")); + } + } + + Ok(()) +} + +/// Removes tags from the specified files. +/// +/// This will not remove the files themselves from the metadata document, even +/// if all the keywords are gone - while keywords are currently the only +/// metadata stored for each file, Ableton could potentially add +/// additional data in future versions. +pub fn remove_tags( + doc: &mut FolderMetadata, + mut files: HashSet, + tags: &[String], +) -> anyhow::Result<()> { + let item_count = doc.item_count(); + + for i in 1..=item_count { + let mut tags_removed = Vec::new(); + + let item = ItemSelector::new(i)?; + + let filename = doc.get_filename(&item)?; + + if files.take(&filename).is_some() { + let keyword_count = doc.keyword_count(&item); + + let mut deleted_count = 0; + + // We iterate in reverse to avoid invalidating the indices + // when elements get deleted. + for i in (1..=keyword_count).rev() { + let keyword = doc.get_keyword(&item, i)?; + + if tags.contains(&keyword) { + doc.delete_keyword(&item, i)?; + tags_removed.push(keyword); + + deleted_count += 1; + } + } + + if deleted_count == keyword_count { + doc.delete_keywords(&item)?; + } + + if !tags_removed.is_empty() { + info!( + "Removing tags from {}: {}", + &filename, + tags_removed.join(", ") + ); + } + } + } + + Ok(()) +} + +/// Removed all tags from the specified files. +/// +/// This will not remove the files themselves from the metadata document - +/// while keywords are currently the only metadata stored for each file, +/// Ableton could potentially add additional data in future versions. +pub fn remove_all_tags(doc: &mut FolderMetadata, mut files: HashSet) -> anyhow::Result<()> { + let item_count = doc.item_count(); + + for i in 1..=item_count { + let item = ItemSelector::new(i)?; + + let filename = doc.get_filename(&item)?; + + if files.take(&filename).is_some() { + doc.delete_keywords(&item)?; + + info!("Removing all tags from {}", &filename); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_add_tags() -> anyhow::Result<()> { + let initial = include_str!("test_data/initial.xml"); + let expected = include_str!("test_data/tags_added.xml"); + + let mut meta = FolderMetadata::from_xmp_str(initial)?; + + let mut files = HashSet::new(); + + files.insert("bd1.wav".into()); + files.insert("bd2.wav".into()); + files.insert("bd3.wav".into()); + + add_tags(&mut meta, files, &["Drums|Kick".into(), "CustomTag".into()])?; + + assert!(meta.is_dirty()); + pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n")); + + Ok(()) + } + + #[test] + fn should_remove_tags() -> anyhow::Result<()> { + let initial = include_str!("test_data/initial.xml"); + let expected = include_str!("test_data/tags_removed.xml"); + + let mut meta = FolderMetadata::from_xmp_str(initial)?; + + let mut files = HashSet::new(); + + files.insert("bd1.wav".into()); + files.insert("bd2.wav".into()); + files.insert("bd3.wav".into()); + + remove_tags( + &mut meta, + files, + &["Creator|17cupsofcoffee".into(), "NonExistentTag".into()], + )?; + + assert!(meta.is_dirty()); + pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n")); + + Ok(()) + } + + #[test] + fn should_remove_all_tags() -> anyhow::Result<()> { + let initial = include_str!("test_data/initial.xml"); + let expected = include_str!("test_data/tags_removed_all.xml"); + + let mut meta = FolderMetadata::from_xmp_str(initial)?; + + let mut files = HashSet::new(); + + files.insert("bd1.wav".into()); + files.insert("bd2.wav".into()); + files.insert("bd3.wav".into()); + + remove_all_tags(&mut meta, files)?; + + assert!(meta.is_dirty()); + pretty_assertions::assert_eq!(meta.to_xml().unwrap(), expected.replace("\r\n", "\n")); + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..54fd9fc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,179 @@ +mod commands; + +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; + +use std::fs; +use std::path::PathBuf; + +use anyhow::Context; +use clap::{Args, Parser, Subcommand}; +use glob::glob; + +use livemeta::{self, FolderMetadata}; +use tracing::{info, warn}; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Adds tags to a set of files. + Add(TagChangeArgs), + + /// Removes tags from a set of files. + Remove(TagChangeArgs), + + /// Removes all tags from a set of files. + RemoveAll(FilesystemArgs), +} + +/// CLI flags for operating on files. +#[derive(Args, Debug)] +struct FilesystemArgs { + /// A glob pattern specifying which files should be processed. + #[arg(short, long, global(true), value_name = "GLOB", default_value = "*")] + include: String, + + /// Saves changes to the filesystem. Run without this first, to make sure you're tagging the correct files! + #[arg(short, long, global(true))] + commit: bool, + + /// Creates backups of any changed metadata. + #[arg(short, long, global(true))] + backup: bool, +} + +/// CLI flags for batch tag operations. +#[derive(Args, Debug)] +struct TagChangeArgs { + /// The tags to apply to the matched files. + #[arg(required(true))] + tags: Vec, + + #[command(flatten)] + fs: FilesystemArgs, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + tracing_subscriber::fmt().with_target(false).init(); + + match cli.command { + Command::Add(args) => process_xmp(&args.fs, |doc, files| { + commands::add_tags(doc, files, &args.tags) + })?, + + Command::Remove(args) => process_xmp(&args.fs, |doc, files| { + commands::remove_tags(doc, files, &args.tags) + })?, + + Command::RemoveAll(args) => process_xmp(&args, commands::remove_all_tags)?, + } + + Ok(()) +} + +/// Finds all files matching the provided parameters, applies some logic to each folder's +/// metadata document (creating one from scratch if needed), then saves to disk if +/// changes have been made. +fn process_xmp(args: &FilesystemArgs, mut action: F) -> anyhow::Result<()> +where + F: FnMut(&mut FolderMetadata, HashSet) -> anyhow::Result<()>, +{ + let folders = search_for_sample_folders(&args.include)?; + + for (folder, files) in folders { + info!("Processing {}", folder.display()); + + let xmp_path = livemeta::get_folder_metadata_path(&folder); + + let (mut xmp, new_file) = if xmp_path.exists() { + (FolderMetadata::from_xmp_file(&xmp_path)?, false) + } else { + (FolderMetadata::new()?, true) + }; + + action(&mut xmp, files)?; + + if xmp.is_dirty() { + xmp.set_creator_tool("Updated by LiveTagger")?; + + if new_file { + xmp.update_create_date()?; + } else { + xmp.update_metadata_date()?; + } + + if args.commit { + if args.backup { + let backup_path = xmp_path.with_extension("xmp.bak"); + + fs::rename(&xmp_path, &backup_path)?; + info!("Backup written to {}", backup_path.display()) + } + + fs::write(&xmp_path, &xmp.to_xml()?)?; + + info!("Metadata updated for {}", folder.display()) + } + } else { + info!("No changes required for {}", folder.display()); + } + } + + if !args.commit { + warn!("Run again with --commit to apply the above changes!"); + } + + Ok(()) +} + +/// Finds all sample files matching a given glob, as well as their corresponding parent folders. +fn search_for_sample_folders(include: &str) -> anyhow::Result>> { + let mut folders: HashMap> = HashMap::new(); + + for entry in glob(include).context("Invalid include glob")? { + let path = entry.context("Invalid path")?; + + if livemeta::is_metadata(&path) || path.is_dir() { + continue; + } + + if !livemeta::is_supported_sample_format(&path) { + info!( + "Skipping {} as it doesn't look like an audio file", + path.display() + ); + + continue; + } + + let (Some(parent), Some(filename)) = + (path.parent(), path.file_name().and_then(OsStr::to_str)) + else { + continue; + }; + + match folders.get_mut(parent) { + Some(folder) => { + folder.insert(filename.to_string()); + } + + None => { + let mut set = HashSet::new(); + set.insert(filename.to_string()); + + folders.insert(parent.to_path_buf(), set); + } + } + } + + Ok(folders) +} diff --git a/test/TestData/WithAllTagsRemoved.xml b/src/test_data/initial.xml similarity index 73% rename from test/TestData/WithAllTagsRemoved.xml rename to src/test_data/initial.xml index c5b0547..5a91278 100644 --- a/test/TestData/WithAllTagsRemoved.xml +++ b/src/test_data/initial.xml @@ -1,7 +1,9 @@ - - + application/vnd.ableton.folder folder @@ -19,16 +21,14 @@ bd2.wav - Drums|Kick Creator|17cupsofcoffee - - ch.wav - + Ableton Index 12.1 + 2024-10-10T20:42:58+01:00 - \ No newline at end of file + diff --git a/test/TestData/WithTagsAdded.xml b/src/test_data/tags_added.xml similarity index 66% rename from test/TestData/WithTagsAdded.xml rename to src/test_data/tags_added.xml index 0175e80..e48c672 100644 --- a/test/TestData/WithTagsAdded.xml +++ b/src/test_data/tags_added.xml @@ -1,7 +1,9 @@ - - + - + application/vnd.ableton.folder folder @@ -12,6 +14,7 @@ Drums|Kick Creator|17cupsofcoffee + CustomTag @@ -19,23 +22,25 @@ bd2.wav - Drums|Kick Creator|17cupsofcoffee + Drums|Kick + CustomTag - ch.wav + bd3.wav - Drums|Hihat - Drums|Hihat|Closed Hihat - Creator|17cupsofcoffee + Drums|Kick + CustomTag + Ableton Index 12.1 + 2024-10-10T20:42:58+01:00 - \ No newline at end of file + diff --git a/test/TestData/WithTagsRemoved.xml b/src/test_data/tags_removed.xml similarity index 56% rename from test/TestData/WithTagsRemoved.xml rename to src/test_data/tags_removed.xml index d2a250f..eedb18c 100644 --- a/test/TestData/WithTagsRemoved.xml +++ b/src/test_data/tags_removed.xml @@ -1,7 +1,9 @@ - - + - + application/vnd.ableton.folder folder @@ -16,17 +18,11 @@ bd2.wav - - - Drums|Kick - - - - - ch.wav + Ableton Index 12.1 + 2024-10-10T20:42:58+01:00 - \ No newline at end of file + diff --git a/src/test_data/tags_removed_all.xml b/src/test_data/tags_removed_all.xml new file mode 100644 index 0000000..9b19186 --- /dev/null +++ b/src/test_data/tags_removed_all.xml @@ -0,0 +1,23 @@ + + + + application/vnd.ableton.folder + folder + + + + bd1.wav + + + bd2.wav + + + + Ableton Index 12.1 + 2024-10-10T20:42:58+01:00 + + + diff --git a/test/AbletonMetadataTests.cs b/test/AbletonMetadataTests.cs deleted file mode 100644 index bf4c500..0000000 --- a/test/AbletonMetadataTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace LiveTagger.Tests; - -public class AbletonMetadataTests -{ - [Theory] - [InlineData("C:/foo/Ableton Folder Info/file.txt", true)] - [InlineData("C:/foo/metadata.asd", true)] - [InlineData("C:/foo/metadata.ASD", true)] - [InlineData("C:/foo/sound.wav", false)] - public void ShouldDetectMetadata(string path, bool isValid) - { - Assert.Equal(isValid, AbletonMetadata.IsMetadata(path)); - } - - [Fact] - public void ShouldReturnXmpFilePath() - { - var xmpPath = AbletonMetadata.GetXmpFilePath("C:/foo"); - - Assert.Equivalent(new FileInfo("C:/foo/Ableton Folder Info/dc66a3fa-0fe1-5352-91cf-3ec237e9ee90.xmp"), new FileInfo(xmpPath)); - } - - [Theory] - [InlineData("sound.wav", true)] - [InlineData("sound.wave", true)] - [InlineData("sound.aif", true)] - [InlineData("sound.aiff", true)] - [InlineData("sound.flac", true)] - [InlineData("sound.ogg", true)] - [InlineData("sound.mp3", true)] - [InlineData("sound.mp4", true)] - [InlineData("sound.m4a", true)] - [InlineData("sound.WAV", true)] - [InlineData("sound.exe", false)] - public void ShouldDetectSampleFiles(string filename, bool isValid) - { - Assert.Equal(isValid, AbletonMetadata.IsSupportedSampleFormat(filename)); - } -} diff --git a/test/LiveTaggerTests.csproj b/test/LiveTaggerTests.csproj deleted file mode 100644 index f4f3d08..0000000 --- a/test/LiveTaggerTests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net8.0 - enable - enable - - false - true - true - - - - - - - - - - - - - - - - - - diff --git a/test/TestData/Empty.xml b/test/TestData/Empty.xml deleted file mode 100644 index 0175e80..0000000 --- a/test/TestData/Empty.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - application/vnd.ableton.folder - folder - - - - bd1.wav - - - Drums|Kick - Creator|17cupsofcoffee - - - - - bd2.wav - - - Drums|Kick - Creator|17cupsofcoffee - - - - - ch.wav - - - Drums|Hihat - Drums|Hihat|Closed Hihat - Creator|17cupsofcoffee - - - - - - - - \ No newline at end of file diff --git a/test/TestUtils.cs b/test/TestUtils.cs deleted file mode 100644 index f5265de..0000000 --- a/test/TestUtils.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace LiveTagger.Tests; - -/// -/// Utilities for testing. -/// -/// Based on https://www.honlsoft.com/blog/2022-03-26-unit-testing-reading-reference-data/. -/// -public static class TestUtils -{ - public static string ReadFileAsString(string file, [CallerFilePath] string filePath = "") - { - var directoryPath = Path.GetDirectoryName(filePath); - var fullPath = Path.Join(directoryPath, file); - return File.ReadAllText(fullPath); - } -} \ No newline at end of file diff --git a/test/XmpTests.cs b/test/XmpTests.cs deleted file mode 100644 index 823195f..0000000 --- a/test/XmpTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Xml.Linq; -using System.Xml.XPath; -using LiveTagger.XmlConstants; - -namespace LiveTagger.Tests; - -public class XmpTests -{ - [Fact] - public void ShouldAddTags() - { - var xmp = new Xmp(); - - xmp.AddTags( - ["bd1.wav", "bd2.wav"], - ["Drums|Kick"] - ); - - xmp.AddTags( - ["ch.wav"], - ["Drums|Hihat", "Drums|Hihat|Closed Hihat"] - ); - - xmp.AddTags( - ["bd1.wav", "bd2.wav", "ch.wav"], - ["Creator|17cupsofcoffee"] - ); - - Assert.Equal( - XElement.Parse(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml")), - xmp.Xml, - XNode.EqualityComparer - ); - - Assert.True(xmp.IsDirty); - } - - [Fact] - public void ShouldRemoveTags() - { - var xmp = Xmp.FromString(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml")); - - xmp.RemoveTags( - ["bd1.wav", "bd2.wav", "ch.wav"], - ["Creator|17cupsofcoffee"] - ); - - xmp.RemoveTags( - ["ch.wav"], - ["Drums|Hihat", "Drums|Hihat|Closed Hihat"] - ); - - Assert.Equal( - XElement.Parse(TestUtils.ReadFileAsString("TestData/WithTagsRemoved.xml")), - xmp.Xml, - XNode.EqualityComparer - ); - - Assert.True(xmp.IsDirty); - } - - [Fact] - public void ShouldRemoveAllTags() - { - var xmp = Xmp.FromString(TestUtils.ReadFileAsString("TestData/WithTagsAdded.xml")); - - xmp.RemoveTags( - ["ch.wav"] - ); - - Assert.Equal( - XElement.Parse(TestUtils.ReadFileAsString("TestData/WithAllTagsRemoved.xml")), - xmp.Xml, - XNode.EqualityComparer - ); - - Assert.True(xmp.IsDirty); - } -}