From 414e4c2ae8dc9a21f52fde6de71a1c91bc2fc969 Mon Sep 17 00:00:00 2001 From: "Michael G. Kazakov" Date: Thu, 8 Feb 2024 20:40:49 +0000 Subject: [PATCH] Writing tags (#153) * Implementing routines to write the Finder Tags * Expanded the WritePListObject function to support long strings and unicode strings * Added routines to write tags into actual files * Added a function that gathers all tags on the filesystem via Spotlight * Added comments --- Source/Utility/include/Utility/Tags.h | 44 +++- Source/Utility/source/Tags.cpp | 317 +++++++++++++++++++++++++- Source/Utility/tests/Tags_UT.mm | 194 ++++++++++++++++ 3 files changed, 548 insertions(+), 7 deletions(-) diff --git a/Source/Utility/include/Utility/Tags.h b/Source/Utility/include/Utility/Tags.h index dbc86dc28..3704aa976 100644 --- a/Source/Utility/include/Utility/Tags.h +++ b/Source/Utility/include/Utility/Tags.h @@ -5,14 +5,15 @@ #include #include #include +#include +#include namespace nc::utility { class Tags { public: - enum class Color : unsigned char - { + enum class Color : unsigned char { None = 0, Gray = 1, Green = 2, @@ -32,18 +33,37 @@ class Tags // Parses the "com.apple.FinderInfo" xattr and extracts a tag color if any is present. // Returns an empty vector as an error mechanism. static std::vector ParseFinderInfo(std::span _bytes) noexcept; - + // Loads the contents an the xattrs and processes it with ParseMDItemUserTags static std::vector ReadMDItemUserTags(int _fd) noexcept; - + // Loads the contents an the xattrs and processes it with ParseFinderInfo static std::vector ReadFinderInfo(int _fd) noexcept; // Loads tags from MDItemUserTags (1st priority) or from FinderInfo(2nd priority), works with file handles static std::vector ReadTags(int _fd) noexcept; - + // Loads tags from MDItemUserTags (1st priority) or from FinderInfo(2nd priority), works with file paths static std::vector ReadTags(const std::filesystem::path &_path) noexcept; + + // Composes a binary blob representing the contents of the "com.apple.metadata:_kMDItemUserTags" xattr corresponding + // to the specified list of tags. Empty blob is returned if no tags were provided. + static std::vector BuildMDItemUserTags(std::span _tags) noexcept; + + // Writes the "com.apple.metadata:_kMDItemUserTags" and "com.apple.FinderInfo" xattrs to the specified file + // according to the provided set of tags. + static bool WriteTags(int _fd, std::span _tags) noexcept; + + // Writes the "com.apple.metadata:_kMDItemUserTags" and "com.apple.FinderInfo" xattrs to the specified file + // according to the provided set of tags. + static bool WriteTags(const std::filesystem::path &_path, std::span _tags) noexcept; + + // Executes a "kMDItemUserTags=*" query by Spotlight to gather all indexed items on the filesystem that contain any + // tags. + static std::vector GatherAllItemsWithTags() noexcept; + + // Gather a current set of tags used by the items on the filesystem. + static std::vector GatherAllItemsTags() noexcept; }; // Non-owning class that represent a text label and a color of a tag. @@ -62,3 +82,17 @@ class Tags::Tag }; } // namespace nc::utility + +namespace std { + +template <> +class hash +{ +public: + size_t operator()(const nc::utility::Tags::Tag &_tag) const noexcept + { + return std::hash{}(_tag.Label()) + std::to_underlying(_tag.Color()); + } +}; + +} // namespace std diff --git a/Source/Utility/source/Tags.cpp b/Source/Utility/source/Tags.cpp index 9f4e85290..44afc057d 100644 --- a/Source/Utility/source/Tags.cpp +++ b/Source/Utility/source/Tags.cpp @@ -9,12 +9,15 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include namespace nc::utility { @@ -345,6 +348,15 @@ std::vector Tags::ParseFinderInfo(std::span _bytes) return {}; } +static void SetFinderInfoLabel(std::span _bytes, Tags::Color _color) noexcept +{ + if( _bytes.size() == 32 ) { + const uint8_t orig_b = _bytes[9]; + const uint8_t new_b = (orig_b & 0xF1) | static_cast(std::to_underlying(_color) << 1); + _bytes[9] = new_b; + } +} + std::vector Tags::ReadMDItemUserTags(int _fd) noexcept { assert(_fd >= 0); @@ -378,7 +390,7 @@ std::vector Tags::ReadTags(int _fd) noexcept // 1st - try MDItemUserTags const bool has_usertags = - memmem(buf.data(), res, g_MDItemUserTags, std::string_view{g_MDItemUserTags}.length()) != nullptr; + memmem(buf.data(), res, g_MDItemUserTags, std::string_view{g_MDItemUserTags}.length() + 1) != nullptr; if( has_usertags ) { auto tags = ReadMDItemUserTags(_fd); if( !tags.empty() ) @@ -386,7 +398,8 @@ std::vector Tags::ReadTags(int _fd) noexcept } // 2nd - try FinderInfo - const bool has_finfo = memmem(buf.data(), res, g_FinderInfo, std::string_view{g_FinderInfo}.length()) != nullptr; + const bool has_finfo = + memmem(buf.data(), res, g_FinderInfo, std::string_view{g_FinderInfo}.length() + 1) != nullptr; if( !has_finfo ) return {}; @@ -406,6 +419,306 @@ std::vector Tags::ReadTags(const std::filesystem::path &_path) noexce return tags; } +static void WriteVarSize(unsigned char _marker_type, size_t _size, std::pmr::vector &_dst) +{ + if( _size < 15 ) { + _dst.push_back(std::byte{static_cast(_marker_type + _size)}); + } + else if( _size < 256 ) { + _dst.push_back(std::byte{static_cast(_marker_type + 15)}); + _dst.push_back(std::byte{0x10}); + _dst.push_back(std::byte{static_cast(_size)}); + } + else if( _size < 65536 ) { + _dst.push_back(std::byte{static_cast(_marker_type + 15)}); + _dst.push_back(std::byte{0x11}); + _dst.push_back(std::byte{static_cast(_size >> 8)}); + _dst.push_back(std::byte{static_cast(_size & 0xFF)}); + } + else { + _dst.push_back(std::byte{static_cast(_marker_type + 15)}); + _dst.push_back(std::byte{0x12}); + _dst.push_back(std::byte{static_cast((_size >> 24) & 0xFF)}); + _dst.push_back(std::byte{static_cast((_size >> 16) & 0xFF)}); + _dst.push_back(std::byte{static_cast((_size >> 8) & 0xFF)}); + _dst.push_back(std::byte{static_cast((_size >> 0) & 0xFF)}); + } + // not supporting sizes larger than 4GB. + // Finder actually doesn't allow even tags longer that 255 bytes +} + +static std::pmr::vector WritePListObject(const Tags::Tag &_tag, std::pmr::memory_resource &_mem) noexcept +{ + // NB! Finder does sometimes skip the color information if the label text matches some of the predefined ones. + // I don't understand the logic on write it makes these decisions. + // It's possible to do that as well, but then binary blobs will be a bit different - need to update the test corpus. + // So for now the color information is written unconditionally (of course if that color is not None) + std::pmr::vector dst(&_mem); + const std::string &label = _tag.Label(); + const bool is_ascii = std::ranges::all_of(label, [](auto _c) { return static_cast(_c) <= 0x7F; }); + if( is_ascii ) { + const size_t len_color = _tag.Color() == Tags::Color::None ? 0 : 2; + const size_t len = label.length() + len_color; + + // write the byte marker and size + WriteVarSize(0x50, len, dst); + + // write the label + dst.insert(dst.end(), + reinterpret_cast(label.data()), + reinterpret_cast(label.data() + label.length())); + if( len_color != 0 ) { + // write the color if it's not None + dst.push_back(std::byte{'\x0a'}); + dst.push_back(std::byte{static_cast('0' + std::to_underlying(_tag.Color()))}); + } + return dst; + } + else { + // Build CF strings out of our label + base::CFStackAllocator alloc; + auto cf_str = + base::CFPtr::adopt(CFStringCreateWithBytesNoCopy(alloc, + reinterpret_cast(label.data()), + label.length(), + kCFStringEncodingUTF8, + false, + kCFAllocatorNull)); + if( !cf_str ) + return {}; // corrupted utf8? + + // Calculate the about of bytes required to store it as UTF16BE + const CFRange range = CFRangeMake(0, CFStringGetLength(cf_str.get())); + CFIndex target_size = 0; + const CFIndex converted = + CFStringGetBytes(cf_str.get(), range, kCFStringEncodingUTF16BE, ' ', false, nullptr, 0, &target_size); + if( converted != range.length ) + return {}; // corrupted utf8? + + assert(target_size % 2 == 0); + const size_t len_color = _tag.Color() == Tags::Color::None ? 0 : 2; + const size_t len = target_size / 2 + len_color; + + // write the byte marker and size + WriteVarSize(0x60, len, dst); + + // write the label + const size_t label_pos = dst.size(); + dst.resize(dst.size() + target_size); + CFStringGetBytes(cf_str.get(), + range, + kCFStringEncodingUTF16BE, + ' ', + false, + reinterpret_cast(dst.data() + label_pos), + target_size, + &target_size); + + if( len_color != 0 ) { + // write the color if it's not None + dst.push_back(std::byte{'\x00'}); + dst.push_back(std::byte{'\x0a'}); + dst.push_back(std::byte{'\x00'}); + dst.push_back(std::byte{static_cast('0' + std::to_underlying(_tag.Color()))}); + } + + return dst; + } +} + +std::vector Tags::BuildMDItemUserTags(const std::span _tags) noexcept +{ + if( _tags.empty() ) + return {}; + + std::array mem_buffer; + std::pmr::monotonic_buffer_resource mem_resource(mem_buffer.data(), mem_buffer.size()); + + // Build serialized representation of the tags + std::pmr::vector> objects(&mem_resource); + for( auto &tag : _tags ) { + objects.emplace_back(WritePListObject(tag, mem_resource)); + } + + if( objects.size() > 14 ) { + // for now the algorithm is simpified to support only up to 14 tags simultaneously, which will be enough unless + // the system is abused. + objects.resize(14); + } + + std::pmr::vector offsets; // offset of every object written into the plist will be gathered here + + // Write the magick prologue + std::pmr::vector plist; + plist.insert(plist.end(), + reinterpret_cast(g_Prologue.data()), + reinterpret_cast(g_Prologue.data() + g_Prologue.length())); + + // Write an array object with up to 14 objects + offsets.push_back(plist.size()); + plist.push_back(std::byte{static_cast(0xA0 + objects.size())}); + + // Write the object references + for( size_t i = 0; i < objects.size(); ++i ) + plist.push_back(std::byte{static_cast(i + 1)}); + + // Write the objects themselves + for( auto &object : objects ) { + offsets.push_back(plist.size()); + plist.insert(plist.end(), object.begin(), object.end()); + } + + // Deduce the stride of the offset table + size_t offset_int_size = 1; + if( const size_t max = *std::max_element(offsets.begin(), offsets.end()); max > 255 ) { + abort(); // TODO: implement + } + + // Compose the trailer to be written later on + Trailer trailer; + memset(&trailer, 0, sizeof(trailer)); + trailer.offset_int_size = static_cast(offset_int_size); + trailer.object_ref_size = 1; + trailer.num_objects = std::byteswap(static_cast(objects.size() + 1)); + trailer.offset_table_offset = std::byteswap(static_cast(plist.size())); + + // Write the offset table + for( const size_t offset : offsets ) { + if( offset_int_size == 1 ) { + plist.push_back(std::byte{static_cast(offset)}); + } + else { + abort(); // TODO: implement + } + } + + // Write the trailer + plist.insert(plist.end(), + reinterpret_cast(&trailer), + reinterpret_cast(&trailer) + sizeof(trailer)); + + // Done. + return {plist.begin(), plist.end()}; +} + +static bool ClearAllTags(int _fd) +{ + std::array buf; // Given XATTR_MAXNAMELEN=127, this allows to read up to 64 max-len names + const ssize_t buf_len = flistxattr(_fd, buf.data(), buf.size(), 0); + if( buf_len < 0 ) + return false; + + if( buf_len == 0 ) + return true; // nothing to do + + const bool has_usertags = memmem(buf.data(), buf_len, g_MDItemUserTags, strlen(g_MDItemUserTags) + 1) != nullptr; + if( has_usertags ) { + if( fremovexattr(_fd, g_MDItemUserTags, 0) != 0 ) + return false; + } + + const bool has_finfo = memmem(buf.data(), buf_len, g_FinderInfo, strlen(g_FinderInfo) + 1) != nullptr; + if( has_finfo ) { + std::array finder_info; + const ssize_t ff_read = fgetxattr(_fd, g_FinderInfo, finder_info.data(), finder_info.size(), 0, 0); + if( ff_read != finder_info.size() ) + return false; + + SetFinderInfoLabel(finder_info, Tags::Color::None); + if( fsetxattr(_fd, g_FinderInfo, finder_info.data(), finder_info.size(), 0, 0) != 0 ) + return false; + } + + return true; +} + +bool Tags::WriteTags(int _fd, std::span _tags) noexcept +{ + if( _tags.empty() ) { + return ClearAllTags(_fd); + } + + // it's faster to first get a list of xattrs and only if one was found to read it than to try reading upfront as + // a probing mechanism. + std::array buf; // Given XATTR_MAXNAMELEN=127, this allows to read up to 64 max-len names + const ssize_t res = flistxattr(_fd, buf.data(), buf.size(), 0); + if( res < 0 ) + return false; + + auto blob = BuildMDItemUserTags(_tags); + if( fsetxattr(_fd, g_MDItemUserTags, blob.data(), blob.size(), 0, 0) != 0 ) + return false; + + std::array finder_info; + finder_info.fill(0); + const bool has_finfo = memmem(buf.data(), res, g_FinderInfo, strlen(g_FinderInfo) + 1) != nullptr; + if( has_finfo ) { + const ssize_t ff_read = fgetxattr(_fd, g_FinderInfo, finder_info.data(), finder_info.size(), 0, 0); + if( ff_read != finder_info.size() ) + return false; + } + SetFinderInfoLabel(finder_info, _tags.front().Color()); + + if( fsetxattr(_fd, g_FinderInfo, finder_info.data(), finder_info.size(), 0, 0) != 0 ) + return false; + + return true; +} + +bool Tags::WriteTags(const std::filesystem::path &_path, std::span _tags) noexcept +{ + const int fd = open(_path.c_str(), O_RDWR | O_NONBLOCK); // TODO: is read-write required to change xattr?? + if( fd < 0 ) + return false; + + const bool res = WriteTags(fd, _tags); + + close(fd); + + return res; +} + +std::vector Tags::GatherAllItemsWithTags() noexcept +{ + const CFStringRef query_string = CFSTR("kMDItemUserTags=*"); + + const base::CFPtr query = + base::CFPtr::adopt(MDQueryCreate(nullptr, query_string, nullptr, nullptr)); + if( !query ) + return {}; + + const bool query_result = MDQueryExecute(query.get(), kMDQuerySynchronous); + if( !query_result ) + return {}; + + std::vector result; + for( long i = 0, e = MDQueryGetResultCount(query.get()); i < e; ++i ) { + const MDItemRef item = static_cast(const_cast(MDQueryGetResultAtIndex(query.get(), i))); + base::CFPtr item_path = + base::CFPtr::adopt(static_cast(MDItemCopyAttribute(item, kMDItemPath))); + if( item_path ) { + result.emplace_back(base::CFStringGetUTF8StdString(item_path.get())); + } + } + + return result; +} + +std::vector Tags::GatherAllItemsTags() noexcept +{ + auto files = GatherAllItemsWithTags(); + + robin_hood::unordered_flat_set tags; + for( auto &file : files ) { + auto file_tags = ReadTags(file); // this can be actually done in multiple threads... + for( auto &file_tag : file_tags ) + tags.emplace(file_tag); + } + + // any sort??? + return {tags.begin(), tags.end()}; +} + Tags::Tag::Tag(const std::string *const _label, const Tags::Color _color) noexcept : m_TaggedPtr{reinterpret_cast(reinterpret_cast(_label) | static_cast(std::to_underlying(_color)))} diff --git a/Source/Utility/tests/Tags_UT.mm b/Source/Utility/tests/Tags_UT.mm index b0f69d1e3..894ccd40d 100644 --- a/Source/Utility/tests/Tags_UT.mm +++ b/Source/Utility/tests/Tags_UT.mm @@ -3,7 +3,12 @@ #include "UnitTests_main.h" #include #include +#include +#include +#include #include +#include +#include #include using nc::utility::Tags; @@ -297,3 +302,192 @@ unlink(path.c_str()); } } + +TEST_CASE(PREFIX "BuildMDItemUserTags") +{ + std::set labels; + auto tag = [&labels](const char *_l, Tags::Color _c) { return Tags::Tag(&*labels.emplace(_l).first, _c); }; + + struct TC { + std::vector labels; + std::vector expected_bytes; + } const tcs[] = { + {{}, {}}, + {{tag("None", Tags::Color::None)}, + {0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x54, 0x4e, 0x6f, 0x6e, 0x65, 0x08, 0x0a, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f}}, + {{tag("Gray", Tags::Color::Gray)}, + {0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x56, 0x47, 0x72, 0x61, 0x79, 0x0a, 0x31, + 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11}}, + {{tag("Green", Tags::Color::Green)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x57, 0x47, 0x72, 0x65, 0x65, 0x6e, 0x0a, 0x32, + 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12}}}, + {{tag("Purple", Tags::Color::Purple)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x58, 0x50, 0x75, 0x72, 0x70, 0x6c, 0x65, 0x0a, + 0x33, 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13}}}, + {{tag("Blue", Tags::Color::Blue)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x56, 0x42, 0x6c, 0x75, 0x65, 0x0a, 0x34, + 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11}}}, + {{tag("Yellow", Tags::Color::Yellow)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x58, 0x59, 0x65, 0x6c, 0x6c, 0x6f, 0x77, 0x0a, + 0x35, 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13}}}, + {{tag("Red", Tags::Color::Red)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x55, 0x52, 0x65, 0x64, 0x0a, 0x36, 0x08, + 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10}}}, + {{tag("Orange", Tags::Color::Orange)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x58, 0x4f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x0a, + 0x37, 0x08, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x13}}}, + {{tag("Blue", Tags::Color::Blue), + tag("Grey", Tags::Color::Gray), + tag("Green", Tags::Color::Green), + tag("Orange", Tags::Color::Orange), + tag("Purple", Tags::Color::Purple), + tag("Red", Tags::Color::Red), + tag("Yellow", Tags::Color::Yellow), + tag("Home", Tags::Color::None)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa8, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x56, 0x42, 0x6c, 0x75, 0x65, 0x0a, 0x34, 0x56, 0x47, 0x72, 0x65, 0x79, 0x0a, 0x31, 0x57, 0x47, 0x72, + 0x65, 0x65, 0x6e, 0x0a, 0x32, 0x58, 0x4f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x0a, 0x37, 0x58, 0x50, 0x75, + 0x72, 0x70, 0x6c, 0x65, 0x0a, 0x33, 0x55, 0x52, 0x65, 0x64, 0x0a, 0x36, 0x58, 0x59, 0x65, 0x6c, 0x6c, + 0x6f, 0x77, 0x0a, 0x35, 0x54, 0x48, 0x6f, 0x6d, 0x65, 0x08, 0x11, 0x18, 0x1f, 0x27, 0x30, 0x39, 0x3f, + 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4d}}}, + {{tag("This is a very long and meaningless tag label", Tags::Color::Red)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x5f, 0x10, 0x2f, 0x54, 0x68, 0x69, + 0x73, 0x20, 0x69, 0x73, 0x20, 0x61, 0x20, 0x76, 0x65, 0x72, 0x79, 0x20, 0x6c, 0x6f, 0x6e, 0x67, + 0x20, 0x61, 0x6e, 0x64, 0x20, 0x6d, 0x65, 0x61, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x73, 0x73, + 0x20, 0x74, 0x61, 0x67, 0x20, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x0a, 0x36, 0x08, 0x0a, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c}}}, + {{tag("Привет!!!", Tags::Color::Blue)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x6b, 0x04, 0x1f, 0x04, 0x40, 0x04, 0x38, + 0x04, 0x32, 0x04, 0x35, 0x04, 0x42, 0x00, 0x21, 0x00, 0x21, 0x00, 0x21, 0x00, 0x0a, 0x00, 0x34, 0x08, + 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21}}}, + {{tag("Синий", Tags::Color::Blue)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x67, 0x04, 0x21, 0x04, 0x38, + 0x04, 0x3d, 0x04, 0x38, 0x04, 0x39, 0x00, 0x0a, 0x00, 0x34, 0x08, 0x0a, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x19}}}, + {{tag("Зеленый", Tags::Color::Green)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x69, 0x04, 0x17, 0x04, 0x35, 0x04, + 0x3b, 0x04, 0x35, 0x04, 0x3d, 0x04, 0x4b, 0x04, 0x39, 0x00, 0x0a, 0x00, 0x32, 0x08, 0x0a, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1d}}}, + {{tag("Это очень длинное имя для тэга длина которого точно не поместится в четыре бита", Tags::Color::Green)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x6f, 0x10, 0x52, 0x04, 0x2d, 0x04, 0x42, 0x04, + 0x3e, 0x00, 0x20, 0x04, 0x3e, 0x04, 0x47, 0x04, 0x35, 0x04, 0x3d, 0x04, 0x4c, 0x00, 0x20, 0x04, 0x34, 0x04, + 0x3b, 0x04, 0x38, 0x04, 0x3d, 0x04, 0x3d, 0x04, 0x3e, 0x04, 0x35, 0x00, 0x20, 0x04, 0x38, 0x04, 0x3c, 0x04, + 0x4f, 0x00, 0x20, 0x04, 0x34, 0x04, 0x3b, 0x04, 0x4f, 0x00, 0x20, 0x04, 0x42, 0x04, 0x4d, 0x04, 0x33, 0x04, + 0x30, 0x00, 0x20, 0x00, 0x20, 0x04, 0x34, 0x04, 0x3b, 0x04, 0x38, 0x04, 0x3d, 0x04, 0x30, 0x00, 0x20, 0x04, + 0x3a, 0x04, 0x3e, 0x04, 0x42, 0x04, 0x3e, 0x04, 0x40, 0x04, 0x3e, 0x04, 0x33, 0x04, 0x3e, 0x00, 0x20, 0x04, + 0x42, 0x04, 0x3e, 0x04, 0x47, 0x04, 0x3d, 0x04, 0x3e, 0x00, 0x20, 0x04, 0x3d, 0x04, 0x35, 0x00, 0x20, 0x04, + 0x3f, 0x04, 0x3e, 0x04, 0x3c, 0x04, 0x35, 0x04, 0x41, 0x04, 0x42, 0x04, 0x38, 0x04, 0x42, 0x04, 0x41, 0x04, + 0x4f, 0x00, 0x20, 0x04, 0x32, 0x00, 0x20, 0x04, 0x47, 0x04, 0x35, 0x04, 0x42, 0x04, 0x4b, 0x04, 0x40, 0x04, + 0x35, 0x00, 0x20, 0x04, 0x31, 0x04, 0x38, 0x04, 0x42, 0x04, 0x30, 0x00, 0x0a, 0x00, 0x32, 0x08, 0x0a, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb1}}}, + {{tag("🤓🥸🤩🥳😏", Tags::Color::None)}, + {{0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xa1, 0x01, 0x6a, 0xd8, 0x3e, 0xdd, 0x13, 0xd8, 0x3e, + 0xdd, 0x78, 0xd8, 0x3e, 0xdd, 0x29, 0xd8, 0x3e, 0xdd, 0x73, 0xd8, 0x3d, 0xde, 0x0f, 0x08, 0x0a, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f}}}, + }; + + for( const auto &tc : tcs ) { + auto bytes = Tags::BuildMDItemUserTags(tc.labels); + CHECK(tc.expected_bytes == + std::vector{reinterpret_cast(bytes.data()), + reinterpret_cast(bytes.data() + bytes.size())}); + } +} + +TEST_CASE(PREFIX "Our tags can be read back by Cocoa") +{ + std::set labels; + auto tag = [&labels](const char *_l, Tags::Color _c) { return Tags::Tag(&*labels.emplace(_l).first, _c); }; + TempTestDir dir; + const auto path = dir.directory / "f.txt"; + close(open(path.c_str(), O_CREAT, S_IRUSR | S_IWUSR)); + struct TC { + std::vector tags; + NSArray *expected_labels; + Tags::Color expected_color; + } const tcs[] = { + {{}, @[], Tags::Color::None}, + {{tag("Hello!", Tags::Color::Green)}, @[@"Hello!"], Tags::Color::Green}, + {{tag("N", Tags::Color::None)}, @[@"N"], Tags::Color::None}, + {{tag("Gy", Tags::Color::Gray)}, @[@"Gy"], Tags::Color::Gray}, + {{tag("Gn", Tags::Color::Green)}, @[@"Gn"], Tags::Color::Green}, + {{tag("P", Tags::Color::Purple)}, @[@"P"], Tags::Color::Purple}, + {{tag("B", Tags::Color::Blue)}, @[@"B"], Tags::Color::Blue}, + {{tag("Y", Tags::Color::Yellow)}, @[@"Y"], Tags::Color::Yellow}, + {{tag("R", Tags::Color::Red)}, @[@"R"], Tags::Color::Red}, + {{tag("O", Tags::Color::Orange)}, @[@"O"], Tags::Color::Orange}, + {{tag("1", Tags::Color::Orange), tag("2", Tags::Color::Blue)}, @[@"1", @"2"], Tags::Color::Orange}, + {{tag("2", Tags::Color::Blue), tag("1", Tags::Color::Orange)}, @[@"2", @"1"], Tags::Color::Blue}, + {{tag("🤡", Tags::Color::Green)}, @[@"🤡"], Tags::Color::Green}, + }; + + for( auto &tc : tcs ) { + CHECK(Tags::WriteTags(path, tc.tags)); + NSURL *url = [[NSURL alloc] initFileURLWithFileSystemRepresentation:path.c_str() + isDirectory:false + relativeToURL:nil]; + + id tag_names; + CHECK([url getResourceValue:&tag_names forKey:NSURLTagNamesKey error:nil]); + if( tc.expected_labels.count ) + CHECK([nc::objc_cast(tag_names) isEqualToArray:tc.expected_labels]); + else + CHECK(tag_names == nil); + + id number; + CHECK([url getResourceValue:&number forKey:NSURLLabelNumberKey error:nil]); + CHECK(nc::objc_cast(number).integerValue == std::to_underlying(tc.expected_color)); + } +} + +TEST_CASE(PREFIX "Spotlight detects items with new tags invented by NC") +{ + // Need to place these temp files into an indexable location (which the temp dir is not) + auto basepath = std::filesystem::path{nc::base::CommonPaths::Library()} / "__nc_testing_tags_ut__"; + std::filesystem::create_directory(basepath); + auto cleanup = at_scope_end([basepath] { std::filesystem::remove_all(basepath); }); + + const std::string label = + fmt::format("Hello! This is a new tag created via Nimble Commander! My PID is {}", getpid()); + const Tags::Color color = Tags::Color::Orange; + + { + // Write a temp file with a newly invented tag + const auto path = basepath / "f.txt"; + CHECK(close(open(path.c_str(), O_CREAT, S_IRUSR | S_IWUSR)) == 0); + CHECK(Tags::WriteTags(path, std::vector{{&label, color}})); + CHECK(system(fmt::format("mdimport {}", path.c_str()).c_str()) == 0); + } + + // Try a few times to find the new tag via Spotlight, need multiple attempts since there's still a race condition + // even after an explicit call to mdimport + for( int attempt = 0; attempt < 10; ++attempt ) { + auto all_tags = Tags::GatherAllItemsTags(); + if( std::ranges::find(all_tags, Tags::Tag{&label, color}) == all_tags.end() ) { + std::this_thread::sleep_for(std::chrono::milliseconds{100}); + continue; + } + + // Sucessfully found the newly created tag among all tags found via Spotlight, i.e. success + return; + } + + // Failed to find the new tag after the number of attempts + FAIL(); +}