8

I'm trying to set the colored labels shown by the finder. The only function I know is setResourceValue. But this needs localized names!

I could image my mother language and english as well, but all others I don't know. I can't believe, that this should be the way.

Is the are translation function, which takes a standard parameter like an enum or int and delivers the localized color name?

I have an running part, but only for two languages (German and English):

let colorNamesEN = [ "None", "Gray", "Green", "Purple", "Blue", "Yellow", "Red", "Orange" ]
let colorNamesDE = [ "",     "Grau", "Grün",  "Lila",   "Blau", "Gelb",   "Rot", "Orange" ]

public enum TagColors : Int8 {
    case None = -1, Gray, Green, Purple, Blue, Yellow, Red, Orange, Max
}

//let theURL : NSURL = NSURL.fileURLWithPath("/Users/dirk/Documents/MyLOG.txt")

extension NSURL {
    // e.g.  theURL.setColors(0b01010101)
    func tagColorValue(tagcolor : TagColors) -> UInt16 {
        return 1 << UInt16(tagcolor.rawValue)
    }

    func addTagColor(tagcolor : TagColors) -> Bool {
        let bits : UInt16 = tagColorValue(tagcolor) | self.getTagColors()
        return setTagColors(bits)
    }

    func remTagColor(tagcolor : TagColors) -> Bool {
        let bits : UInt16 = ~tagColorValue(tagcolor) & self.getTagColors()
        return setTagColors(bits)
    }

    func setColors(tagcolor : TagColors) -> Bool {
        let bits : UInt16 = tagColorValue(tagcolor)
        return setTagColors(bits)
    }

    func setTagColors(colorMask : UInt16) -> Bool {
        // get string for all available and requested bits
        let arr = colorBitsToStrings(colorMask & (tagColorValue(TagColors.Max)-1))

        do {
            try self.setResourceValue(arr, forKey: NSURLTagNamesKey)
            return true
        }
        catch {
            print("Could not write to file \(self.absoluteURL)")
            return false
        }
    }

    func getTagColors() -> UInt16 {
        return getAllTagColors(self.absoluteURL)
    }
}


// let initialBits: UInt8 = 0b00001111
func colorBitsToStrings(colorMask : UInt16) -> NSArray {
    // translate bits to (localized!) color names
    let countryCode = NSLocale.currentLocale().objectForKey(NSLocaleLanguageCode)!

    // I don't know how to automate it for all languages possible!!!!
    let colorNames = countryCode as! String == "de" ? colorNamesDE : colorNamesEN

    var tagArray = [String]()
    var bitNumber : Int = -1   // ignore first loop
    for colorName in colorNames {
        if bitNumber >= 0 {
            if colorMask & UInt16(1<<bitNumber) > 0 {
                tagArray.append(colorName)
            }
        }
        bitNumber += 1
    }
    return tagArray
}


func getAllTagColors(file : NSURL) -> UInt16 {
    var colorMask : UInt16 = 0

    // translate (localized!) color names to bits
    let countryCode = NSLocale.currentLocale().objectForKey(NSLocaleLanguageCode)!
    // I don't know how to automate it for all languages possible!!!!
    let colorNames = countryCode as! String == "de" ? colorNamesDE : colorNamesEN
    var bitNumber : Int = -1   // ignore first loop

    var tags : AnyObject?

    do {
        try file.getResourceValue(&tags, forKey: NSURLTagNamesKey)
        if tags != nil {
            let tagArray = tags as! [String]

            for colorName in colorNames {
                if bitNumber >= 0 {
                    // color name listed?
                    if tagArray.filter( { $0 == colorName } ).count > 0 {
                        colorMask |= UInt16(1<<bitNumber)
                    }
                }
                bitNumber += 1
            }
        }
    } catch {
        // process the error here
    }

    return colorMask
}
Eric Aya
  • 69,473
  • 35
  • 181
  • 253
Peter Silie
  • 825
  • 1
  • 11
  • 16

4 Answers4

9

To set a single color, the setResourceValue API call is indeed what you should use. However, the resource key you should use is NSURLLabelNumberKey, or URLResourceKey.labelNumberKey in Swift 3 (not NSURLTagNamesKey):

enum LabelNumber: Int {
    case none
    case grey
    case green
    case purple
    case blue
    case yellow
    case red
    case orange
}

do {
    // casting to NSURL here as the equivalent API in the URL value type appears borked:
    // setResourceValue(_, forKey:) is not available there, 
    // and setResourceValues(URLResourceValues) appears broken at least as of Xcode 8.1…
    // fix-it for setResourceValues(URLResourceValues) is saying to use [URLResourceKey: AnyObject], 
    // and the dictionary equivalent also gives an opposite compiler error. Looks like an SDK / compiler bug. 
    try (fileURL as NSURL).setResourceValue(LabelNumber.purple.rawValue, forKey: .labelNumberKey)
}
catch {
    print("Error when setting the label number: \(error)")
}

(This is a Swift 3 port of an answer to a related Objective-C question. Tested with Xcode 8.1, macOS Sierra 10.12.1)

To set multiple colors, you can either use the API you've used with setting resource values with the label key. The distinction between these two encodings is described here: http://arstechnica.com/apple/2013/10/os-x-10-9/9/ – basically the label key is internally setting the extended attribute "com.apple.metadata:_kMDItemUserTags" which stores an array of those label strings as a binary plist, whereas the single colour option shown above is setting the 10th byte of 32 byte long extended attribute value "com.apple.FinderInfo".

The "localized" in that key name is a bit confusing in the sense that what is actually being set with it is the set of labels chosen by the user, amongst the label names set by the user. Those label values are indeed localized, but only to the extent where they are set according to the localisation setting when you initially created your account. To demonstrate, these are the label values used by Finder on my system, which I'd set to Finnish localization as a test and restarted Finder, rebooted machine etc:

➜  defaults read com.apple.Finder FavoriteTagNames
(
    "",
    Red,
    Orange,
    Yellow,
    Green,
    Blue,
    Purple,
    Gray
)

The way the data is encoded in that binary plist value is simply the favourite tag name followed by its index in the array (which is fixed to be of length 8, with actual values starting from 1, i.e. matching the seven colours in the order Red, Orange, Yellow, Green, Blue, Purple, Gray). For example:

xattr -p com.apple.metadata:_kMDItemUserTags foobar.png | xxd -r -p | plutil -convert xml1 - -o -
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
    <string>Gray
1</string>
    <string>Purple
3</string>
    <string>Green
2</string>
    <string>Red
6</string>
</array>
</plist>

So, the system localisation is not taken into account, and in fact setting the tag with any string followed by a linefeed, followed by a number between 1–7 will show up in Finder with the colour indicated by the tag's index. However, to know the correct current values to apply to get the tags to be applied from the set of favorite tags (such that both colour and the label match up) you would need to read that key from Finder preferences (key 'FavoriteTagNames' from domain 'com.apple.Finder' which encodes an array of those favourite tag names as shown above).

Ignoring the above complication in case you want to get the label name and colour correct, requiring reading from Finder preferences domain (which you may or may not be able to do, depending on whether your app is sandboxed or not), should you wish to use multiple colours, here's an example solution that sets the colour using extended attribute values directly (I used SOExtendedAttributes to avoid having to touch the unwieldy xattr C APIs):

enum LabelNumber: Int {
    case none
    case gray
    case green
    case purple
    case blue
    case yellow
    case red
    case orange

    // using an enum here is really for illustrative purposes:
    // to know the correct values to apply you would need to read Finder preferences (see body of my response for more detail).
    var label:String? {
        switch self {
        case .none: return nil
        case .gray: return "Gray\n1"
        case .green: return "Green\n2"
        case .purple: return "Purple\n3"
        case .blue: return "Blue\n4"
        case .yellow: return "Yellow\n5"
        case .red: return "Red\n6"
        case .orange: return "Orange\n7"
        }
    }

    static func propertyListData(labels: [LabelNumber]) throws -> Data {
        let labelStrings = labels.flatMap { $0.label }
        let propData = try! PropertyListSerialization.data(fromPropertyList: labelStrings,
                                                           format: PropertyListSerialization.PropertyListFormat.binary,
                                                           options: 0)
        return propData
    }
}

do {
    try (fileURL as NSURL).setExtendedAttributeData(LabelNumber.propertyListData(labels: [.gray, .green]),
                                                     name: "com.apple.metadata:_kMDItemUserTags")
}
catch {
    print("Error when setting the label number: \(error)")
}
Community
  • 1
  • 1
mz2
  • 4,672
  • 1
  • 27
  • 47
  • 1
    Looks like I did indeed find a solution based on the extended attributes API. This particular value is not a binary plist as is suggested in a comment in response to your response. I'll dig things around some more, pretty sure there's a certain two bytes in the com.apple.FinderInfo attribute value that contains this as an options mask. – mz2 Oct 28 '16 at 23:43
  • Well, I got as far as this same conclusion: http://superuser.com/a/295155/157584 – the 10th byte in that 32 bytes long extended attribute value contains the colour info. I'm not sure I'm reading it out quite right yet. – mz2 Oct 29 '16 at 00:03
  • Nevermind, that key contains the value that can be manipulated with this resource value API, i.e. it only contains the first set colour. The key that contains the set of colours is "com.apple.metadata:_kMDItemUserTags" which indeed contains a binary plist. – mz2 Oct 29 '16 at 00:16
  • 1
    Can't say I am super happy with the solution I did find, but I do believe my solution is exhaustively enough at least describing why it's unlikely that a better solution exists (as the labels are not localised to some fixed set that would change based on system localisation, but are fixed after account creation according to localisation used then, and can be manipulated of course by the user as well). – mz2 Oct 29 '16 at 01:07
  • I like your solution, it works, several color labels are set, this is what I needed. Of course, the labels color names are still hardcoded, but as you say it looks like it's not possible to do otherwise. Thanks a lot! :) *Note: if your answer is upvoted enough you will automatically get the bounty in 3 days - if not, I will attribute it myself to your answer.* – Eric Aya Oct 29 '16 at 12:03
  • Thanks! Yep, this all seems to be a bit messy due to backwards compatibility and robustness to changing label names. – mz2 Oct 29 '16 at 13:54
  • I was just reminded that the bounty would be cut in half if I waited for it to be automatically attributed - because there's already an answer, no matter that it's mine - so I prefer giving it to you right now to thank you for your very helpful contribution. Cheers! – Eric Aya Oct 29 '16 at 15:51
  • Your answer gave me an idea, and I've found a (hacky) way to use the tags in the user's language! I read the Finder's plist in ~/Library and extract the localized names, then use them in your enum instead of the hardcoded ones. I suppose I should post another answer, explaining that it's the follow-up of yours. – Eric Aya Oct 29 '16 at 18:45
  • That's basically what I assumed you'd want to do in practice if you make an app that is not sandboxed – the enum was not a very good example to give there I suppose given we know they're definitely not fixed values. Reason I only mentioned in passing that you can indeed read the Finder user defaults domain is that in my understanding that's not plausible in the sandbox (I may be wrong on that?) – mz2 Oct 29 '16 at 19:15
  • I added a comment in my solution in the 2nd code example pointing out the Finder preference thing again. – mz2 Oct 29 '16 at 19:22
  • It _will_ work of course if you request for read access to ~/Library or user's home directory. Curious to see your solution anyway :D – mz2 Oct 29 '16 at 19:25
  • I have posted a follow-up to your answer. – Eric Aya Oct 29 '16 at 20:52
5

I got it working without having to know the color name, thanks to the new URLResourceValues() struct and the tag numbers.

Knowing that each of these tag numbers represents a tag color:

0 None
1 Grey
2 Green
3 Purple
4 Blue
5 Yellow
6 Red
7 Orange

Make a URL of your file:

var url = URL(fileURLWithPath: pathToYourFile)

It has to be a var because we are going to mutate it.

Create a new URLResourceValues instance (also needs to be a variable):

var rv = URLResourceValues()

Set the label number like this:

rv.labelNumber = 2 // green

Finally, write the tag to the file:

do {
    try url.setResourceValues(rv)
} catch {
    print(error.localizedDescription)
}

In our example we've set the number tag to 2 so now this file is labeled with green color.

Eric Aya
  • 69,473
  • 35
  • 181
  • 253
  • Nice! (Side note: the `as NSError` cast is no longer needed in Swift 3.) – Martin R Sep 28 '16 at 15:06
  • That's great. But what's about more than one color? I can assign one 0 - 7 value to .labelNumber. I can not add them nor are they bits which can be combined. – Peter Silie Sep 29 '16 at 11:04
  • @PeterSilie In every document I've found which mentions URLResourceValues there's always only one label color / label number used. My guess is that the Finder has legacy code able to set multiple labels but there is no public API available anymore to do the same. I hope someone will see this one day and prove me wrong... – Eric Aya Sep 29 '16 at 12:06
  • @EricAya: Tag names and colors are stored in an "extended attribute" of the file, see for example http://arstechnica.com/apple/2013/10/os-x-10-9/9/ where the format is analyzed, it is a binary property list. Here are some Swift methods to read/write extended attributes: http://stackoverflow.com/a/38343753/1187415. The color names are actually stored literally in that bplist. – Martin R Oct 26 '16 at 18:42
  • @MartinR Great article, unfortunately I've already read it and wasn't able to use this information to achieve my goal of not using localized names - maybe because it's not possible, maybe because I didn't understand well, so I wanted to be sure, therefore the bounty. I'll try to experiment with your extension when I have time, it looks very promising. Thanks! – Eric Aya Oct 26 '16 at 19:02
2

History

First there was my previous answer, which works for setting one color label to a file: https://stackoverflow.com/a/39751001/2227743.

Then @mz2 posted this excellent answer which successfully sets several color labels to a file and explains the process: https://stackoverflow.com/a/40314367/2227743.

And now this little add-on, a simple follow-up to @mz2's answer.

Solution

I've simply implemented @mz2's suggestion: I've expanded his enum example with methods for fetching the Finder's preferences and extract the correct localized label color names before setting the attributes to the file.

enum LabelColors: Int {
    case none
    case gray
    case green
    case purple
    case blue
    case yellow
    case red
    case orange

    func label(using list: [String] = []) -> String? {
        if list.isEmpty || list.count < 7 {
            switch self {
            case .none: return nil
            case .gray: return "Gray\n1"
            case .green: return "Green\n2"
            case .purple: return "Purple\n3"
            case .blue: return "Blue\n4"
            case .yellow: return "Yellow\n5"
            case .red: return "Red\n6"
            case .orange: return "Orange\n7"
            }
        } else {
            switch self {
            case .none: return nil
            case .gray: return list[0]
            case .green: return list[1]
            case .purple: return list[2]
            case .blue: return list[3]
            case .yellow: return list[4]
            case .red: return list[5]
            case .orange: return list[6]
            }
        }
    }

    static func set(colors: [LabelColors],
                    to url: URL,
                    using list: [String] = []) throws
    {
        // 'setExtendedAttributeData' is part of https://github.com/billgarrison/SOExtendedAttributes
        try (url as NSURL).setExtendedAttributeData(propertyListData(labels: colors, using: list),
                                                    name: "com.apple.metadata:_kMDItemUserTags")
    }

    static func propertyListData(labels: [LabelColors],
                                 using list: [String] = []) throws -> Data
    {
        let labelStrings = labels.flatMap { $0.label(using: list) }
        return try PropertyListSerialization.data(fromPropertyList: labelStrings,
                                                  format: .binary,
                                                  options: 0)
    }

    static func localizedLabelNames() -> [String] {
        // this doesn't work if the app is Sandboxed:
        // the users would have to point to the file themselves with NSOpenPanel
        let url = URL(fileURLWithPath: "\(NSHomeDirectory())/Library/SyncedPreferences/com.apple.finder.plist")

        let keyPath = "values.FinderTagDict.value.FinderTags"
        if let d = try? Data(contentsOf: url) {
            if let plist = try? PropertyListSerialization.propertyList(from: d,
                                                                       options: [],
                                                                       format: nil),
                let pdict = plist as? NSDictionary,
                let ftags = pdict.value(forKeyPath: keyPath) as? [[AnyHashable: Any]]
            {
                var list = [(Int, String)]()
                // with '.count == 2' we ignore non-system labels
                for item in ftags where item.values.count == 2 {
                    if let name = item["n"] as? String,
                        let number = item["l"] as? Int {
                        list.append((number, name))
                    }
                }
                return list.sorted { $0.0 < $1.0 }.map { "\($0.1)\n\($0.0)" }
            }
        }
        return []
    }
}

Usage:

do {
    // default English label names
    try LabelColors.set(colors: [.yellow, .red],
                        to: fileURL)

    // localized label names
    let list = LabelColors.localizedLabelNames()
    try LabelColors.set(colors: [.green, .blue],
                        to: fileURL,
                        using: list)
} catch {
    print("Error when setting label color(s): \(error)")
}
Community
  • 1
  • 1
Eric Aya
  • 69,473
  • 35
  • 181
  • 253
  • When I tried to implement this, I also had to install 'setExtendedAttributeData' as part of https://github.com/billgarrison/SOExtendedAttributes. But this isn't a Swift module and I'm just starting with this language. What do I have to do for using this C module? Is there any Swift only solution? – Peter Silie Dec 02 '16 at 15:29
  • It's easy to integrate SOExtendedAttributes in your Xcode project (and you don't have to worry about it not being in Swift): download the zip, then drag and drop "NSURL+SOExtendedAttributes.h" and "NSURL+SOExtendedAttributes.m" in your project's navigator, next to your own files. In the appearing panel, the box "copy" should be checked. Then Xcode will ask you if you want to make a bridging header. Say YES. Then in this header, add the line `#import "NSURL+SOExtendedAttributes.h"`: done, you can now use `setExtendedAttributeData`. :) – Eric Aya Dec 02 '16 at 15:40