The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#------------------------------------------------------------------------------
# File:         MacOS.pm
#
# Description:  Read/write MacOS system tags
#
# Revisions:    2017/03/01 - P. Harvey Created
#------------------------------------------------------------------------------

package Image::ExifTool::MacOS;
use strict;
use vars qw($VERSION);
use Image::ExifTool qw(:DataAccess :Utils);

$VERSION = '1.03';

sub MDItemLocalTime($);

my %mdDateInfo = (
    ValueConv => \&MDItemLocalTime,
    PrintConv => '$self->ConvertDateTime($val)',
);

# "mdls" tags
%Image::ExifTool::MacOS::MDItem = (
    WRITE_PROC => \&Image::ExifTool::DummyWriteProc,
    VARS => { NO_ID => 1 },
    GROUPS => { 0 => 'File', 1 => 'MacOS', 2 => 'Other' },
    NOTES => q{
        MDItem tags are extracted using the "mdls" utility.  They are extracted if
        any "MDItem*" tag or the MacOS group is specifically requested, or by
        setting the L<MDItemTags API option|../ExifTool.html#MDItemTags> to 1 or the L<RequestAll API option|../ExifTool.html#RequestAll> to 2 or
        higher.
    },
    MDItemFinderComment => {
        Writable => 1,
        WritePseudo => 1,
        Protected => 1, # (all writable pseudo tags must be protected)
    },
    MDItemFSLabel => {
        Writable => 1,
        WritePseudo => 1,
        Protected => 1, # (all writable pseudo tags must be protected)
        Notes => 'label number: 0-7',
        WriteCheck => '$val =~ /^[0-7]$/ ? undef : "Not an integer in the range 0-7"',
    },
    MDItemFSCreationDate => {
        Writable => 1,
        WritePseudo => 1,
        DelCheck => q{"Can't delete"},
        Protected => 1, # (all writable pseudo tags must be protected)
        Shift => 'Time', # (but not supported yet)
        Notes => 'file creation date. Requires "setfile" for writing',
        Groups => { 2 => 'Time' },
        ValueConv => \&MDItemLocalTime,
        ValueConvInv => sub {
            my $val = shift;
            # convert to local time if value has a time zone
            if ($val =~ /[-+Z]/) {
                my $time = Image::ExifTool::GetUnixTime($val, 1);
                $val = Image::ExifTool::ConvertUnixTime($time, 1) if $time;
            }
            $val =~ s{(\d{4}):(\d{2}):(\d{2})}{$2/$3/$1};   # reformat for setfile
            return $val;
        },
        PrintConv => '$self->ConvertDateTime($val)',
        PrintConvInv => '$self->InverseDateTime($val)',
    },
    MDItemAcquisitionMake         => { Groups => { 2 => 'Camera' } },
    MDItemAcquisitionModel        => { Groups => { 2 => 'Camera' } },
    MDItemAltitude                => { Groups => { 2 => 'Location' } },
    MDItemAperture                => { Groups => { 2 => 'Camera' } },
    MDItemAuthors                 => { Groups => { 2 => 'Author' } },
    MDItemBitsPerSample           => { Groups => { 2 => 'Image' } },
    MDItemCity                    => { Groups => { 2 => 'Location' } },
    MDItemColorSpace              => { Groups => { 2 => 'Image' } },
    MDItemComment                 => { },
    MDItemContentCreationDate     => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemContentModificationDate => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemContentType             => { },
    MDItemContentTypeTree         => { },
    MDItemContributors            => { },
    MDItemCopyright               => { Groups => { 2 => 'Author' } },
    MDItemCountry                 => { Groups => { 2 => 'Location' } },
    MDItemCreator                 => { Groups => { 2 => 'Document' } },
    MDItemDateAdded               => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemDescription             => { },
    MDItemDisplayName             => { },
    MDItemDownloadedDate          => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemEncodingApplications    => { },
    MDItemEXIFGPSVersion          => { Groups => { 2 => 'Location' } },
    MDItemEXIFVersion             => { },
    MDItemExposureMode            => { Groups => { 2 => 'Camera' } },
    MDItemExposureProgram         => { Groups => { 2 => 'Camera' } },
    MDItemExposureTimeSeconds     => { Groups => { 2 => 'Camera' } },
    MDItemFlashOnOff              => { Groups => { 2 => 'Camera' } },
    MDItemFNumber                 => { Groups => { 2 => 'Camera' } },
    MDItemFocalLength             => { Groups => { 2 => 'Camera' } },
    MDItemFSContentChangeDate     => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemFSCreatorCode           => { Groups => { 2 => 'Author' } },
    MDItemFSFinderFlags           => { },
    MDItemFSHasCustomIcon         => { },
    MDItemFSInvisible             => { },
    MDItemFSIsExtensionHidden     => { },
    MDItemFSIsStationery          => { },
    MDItemFSName                  => { },
    MDItemFSNodeCount             => { },
    MDItemFSOwnerGroupID          => { },
    MDItemFSOwnerUserID           => { },
    MDItemFSSize                  => { },
    MDItemFSTypeCode              => { },
    MDItemGPSDateStamp            => { Groups => { 2 => 'Time' } },
    MDItemGPSStatus               => { Groups => { 2 => 'Location' } },
    MDItemGPSTrack                => { Groups => { 2 => 'Location' } },
    MDItemHasAlphaChannel         => { Groups => { 2 => 'Image' } },
    MDItemImageDirection          => { Groups => { 2 => 'Location' } },
    MDItemISOSpeed                => { Groups => { 2 => 'Camera' } },
    MDItemKeywords                => { },
    MDItemKind                    => { },
    MDItemLastUsedDate            => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemLatitude                => { Groups => { 2 => 'Location' } },
    MDItemLogicalSize             => { },
    MDItemLongitude               => { Groups => { 2 => 'Location' } },
    MDItemNumberOfPages           => { },
    MDItemOrientation             => { Groups => { 2 => 'Image' } },
    MDItemOriginApplicationIdentifier => { },
    MDItemOriginMessageID         => { },
    MDItemOriginSenderDisplayName => { },
    MDItemOriginSenderHandle      => { },
    MDItemOriginSubject           => { },
    MDItemPageHeight              => { Groups => { 2 => 'Image' } },
    MDItemPageWidth               => { Groups => { 2 => 'Image' } },
    MDItemPhysicalSize            => { Groups => { 2 => 'Image' } },
    MDItemPixelCount              => { Groups => { 2 => 'Image' } },
    MDItemPixelHeight             => { Groups => { 2 => 'Image' } },
    MDItemPixelWidth              => { Groups => { 2 => 'Image' } },
    MDItemProfileName             => { Groups => { 2 => 'Image' } },
    MDItemRedEyeOnOff             => { Groups => { 2 => 'Camera' } },
    MDItemResolutionHeightDPI     => { Groups => { 2 => 'Image' } },
    MDItemResolutionWidthDPI      => { Groups => { 2 => 'Image' } },
    MDItemSecurityMethod          => { },
    MDItemSpeed                   => { Groups => { 2 => 'Location' } },
    MDItemStateOrProvince         => { Groups => { 2 => 'Location' } },
    MDItemTimestamp               => { Groups => { 2 => 'Time' } }, # (time only)
    MDItemTitle                   => { },
    MDItemUseCount                => { },
    MDItemUsedDates               => { Groups => { 2 => 'Time' }, %mdDateInfo },
    MDItemUserTags                => { },
    MDItemVersion                 => { },
    MDItemWhereFroms              => { },
    MDItemWhiteBalance            => { Groups => { 2 => 'Image' } },
);

# "xattr" tags
%Image::ExifTool::MacOS::XAttr = (
    WRITE_PROC => \&Image::ExifTool::DummyWriteProc,
    GROUPS => { 0 => 'File', 1 => 'MacOS', 2 => 'Other' },
    VARS => { NO_ID => 1 }, # (id's are too long)
    NOTES => q{
        XAttr tags are extracted using the "xattr" utility.  They are extracted if
        any "XAttr*" tag or the MacOS group is specifically requested, or by setting
        the L<XAttrTags API option|../ExifTool.html#XAttrTags> to 1 or the L<RequestAll API option|../ExifTool.html#RequestAll> to 2 or higher.
    },
    'com.apple.FinderInfo' => {
        Name => 'XAttrFinderInfo',
        ConvertBinary => 1,
        # ref https://opensource.apple.com/source/CarbonHeaders/CarbonHeaders-9A581/Finder.h
        ValueConv => q{
            my @a = unpack('a4a4n3x10nx2N', $$val);
            tr/\0//d, $_="'${_}'" foreach @a[0,1];
            return "@a";
        },
        PrintConv => q{
            $val =~ s/^('.*?') ('.*?') //s or return $val;
            my ($type, $creator) = ($1, $2);
            my ($flags, $y, $x, $exFlags, $putAway) = split ' ', $val;
            my $label = ($flags >> 1) & 0x07;
            my $flags = DecodeBits((($exFlags<<16) | $flags) & 0xfff1, {
                0 => 'OnDesk',
                6 => 'Shared',
                7 => 'HasNoInits',
                8 => 'Inited',
                10 => 'CustomIcon',
                11 => 'Stationery',
                12 => 'NameLocked',
                13 => 'HasBundle',
                14 => 'Invisible',
                15 => 'Alias',
                # extended flags
                22 => 'HasRoutingInfo',
                23 => 'ObjectBusy',
                24 => 'CustomBadge',
                31 => 'ExtendedFlagsValid',
            });
            my $str = "Type=$type Creator=$creator Flags=$flags Label=$label Pos=($x,$y)";
            $str .= " Putaway=$putAway" if $putAway;
            return $str;
        },
    },
    'com.apple.quarantine' => {
        Name => 'XAttrQuarantine',
        Writable => 1,
        WritePseudo => 1,
        WriteCheck => '"May only delete this tag"',
        Protected => 1,
        Notes => q{
            quarantine information for files downloaded from the internet.  May only be
            deleted when writing
        },
        # ($a[1] is the time when the quarantine tag was set)
        PrintConv => q{
            my @a = split /;/, $val;
            $a[0] = 'Flags=' . $a[0];
            $a[1] = 'set at ' . ConvertUnixTime(hex $a[1]);
            $a[2] = 'by ' . $a[2];
            return join ' ', @a;
        },
        PrintConvInv => '$val',
    },
    'com.apple.metadata:kMDItemDownloadedDate' => {
        Name => 'XAttrMDItemDownloadedDate',
        Groups => { 2 => 'Time' },
    },
    'com.apple.metadata:kMDItemFinderComment'  => { Name => 'XAttrMDItemFinderComment' },
    'com.apple.metadata:kMDItemWhereFroms'     => { Name => 'XAttrMDItemWhereFroms' },
    'com.apple.metadata:kMDLabel'              => { Name => 'XAttrMDLabel', Binary => 1 },
    'com.apple.ResourceFork'                   => { Name => 'XAttrResourceFork', Binary => 1 },
);

#------------------------------------------------------------------------------
# Convert OS MDItem time string to standard EXIF-formatted local time
# Inputs: 0) time string (eg. "2017-02-21 17:21:43 +0000")
# Returns: EXIF-formatted local time string with timezone
sub MDItemLocalTime($)
{
    my $val = shift;
    $val =~ tr/-/:/;
    $val =~ s/ ?([-+]\d{2}):?(\d{2})/$1:$2/;
    # convert from UTC to local time
    if ($val =~ /\+00:00$/) {
        my $time = Image::ExifTool::GetUnixTime($val);
        $val = Image::ExifTool::ConvertUnixTime($time, 1) if $time;
    }
    return $val;
}

#------------------------------------------------------------------------------
# Set MacOS MDItem and XAttr tags from new tag values
# Inputs: 0) Exiftool ref, 1) file name, 2) list of tags to set
# Returns: 1=something was set OK, 0=didn't try, -1=error (and warning set)
# Notes: There may be errors even if 1 is returned
sub SetMacOSTags($$$)
{
    my ($et, $file, $setTags) = @_;
    my $result = 0;
    my $tag;

    foreach $tag (@$setTags) {
        my ($nvHash, $f, $v, $attr, $cmd, $silentErr);
        my $val = $et->GetNewValue($tag, \$nvHash);
        next unless $nvHash;
        my $overwrite = $et->IsOverwriting($nvHash) or next;
        if ($overwrite < 0) {
            my $operation = $$nvHash{Shift} ? 'Shifting' : 'Conditional replacement';
            $et->Warn("$operation of $tag not yet supported");
            next;
        }
        if ($tag eq 'MDItemFSCreationDate') {
            ($f = $file) =~ s/'/'\\''/g;
            $cmd = "setfile -d '${val}' '${f}'";
        } elsif ($tag eq 'XAttrQuarantine') {
            ($f = $file) =~ s/'/'\\''/g;
            $cmd = "xattr -d com.apple.quarantine '${f}'";
            $silentErr = 256;   # (will get this error if attribute doesn't exist)
        } else {
            ($f = $file) =~ s/(["\\])/\\$1/g;   # escape necessary characters
            $f =~ s/'/'"'"'/g;
            if ($tag eq 'MDItemFinderComment') {
                # (write finder comment using osascript instead of xattr
                # because it is more work to construct the necessary bplist)
                $val = '' unless defined $val;  # set to empty string instead of deleting
                $v = $et->Encode($val, 'UTF8');
                $v =~ s/(["\\])/\\$1/g;
                $v =~ s/'/'"'"'/g;
                $attr = 'comment';
            } else { # $tag eq 'MDItemFSLabel'
                $v = $val ? 8 - $val : 0;       # convert from label to label index (0 for no label)
                $attr = 'label index';
            }
            $cmd = qq(osascript -e 'set fp to POSIX file "$f" as alias' -e \\
                'tell application "Finder" to set $attr of file fp to "$v"');
        }
        my $err = system $cmd . '>/dev/null 2>&1';  # (pipe all output to /dev/null)
        if (not $err) {
            $et->VerboseValue("+ $tag", $val);
            $result = 1;
        } elsif (not $silentErr or $err != $silentErr) {
            $cmd =~ s/ .*//s;
            $et->Warn(qq{Error $err running "$cmd" to set $tag});
            $result = -1 unless $result;
        }
    }
    return $result;
}

#------------------------------------------------------------------------------
# Extract MacOS metadata item tags
# Inputs: 0) ExifTool object ref, 1) file name
sub ExtractMDItemTags($$)
{
    local $_;
    my ($et, $file) = @_;
    my ($fn, $tag, $val);

    ($fn = $file) =~ s/([`"\$\\])/\\$1/g;   # escape necessary characters
    $et->VPrint(0, '(running mdls)');
    my @mdls = `mdls "$fn" 2> /dev/null`;   # get MacOS metadata
    if ($? or not @mdls) {
        $et->Warn('Error running "mdls" to extract MDItem tags');
        return;
    }
    my $tagTablePtr = GetTagTable('Image::ExifTool::MacOS::MDItem');
    $$et{INDENT} .= '| ';
    $et->VerboseDir('MDItem');
    foreach (@mdls) {
        chomp;
        if (ref $val ne 'ARRAY') {
            s/^k?(\w+)\s*= // or next;
            $tag = $1;
            $_ eq '(' and $val = [ ], next; # (start of a list)
            $_ = '' if $_ eq '(null)';
            s/^"// and s/"$//;  # remove quotes if they exist
            $val = $_;
        } elsif ($_ eq ')') {   # (end of a list)
            $_ = $$val[0];
            next unless defined $_;
        } else {
            # add item to list
            s/^    //;          # remove leading spaces
            s/,$//;             # remove trailing comma
            $_ = '' if $_ eq '(null)';
            s/^"// and s/"$//;  # remove quotes if they exist
            $_ = $et->Decode($_, 'UTF8');
            push @$val, $_;
            next;
        }
        # add to Extra tags if not done already
        unless ($$tagTablePtr{$tag}) {
            # check for a date/time format
            my %tagInfo;
            %tagInfo = (
                Groups => { 2 => 'Time' },
                ValueConv => \&MDItemLocalTime,
                PrintConv => '$self->ConvertDateTime($val)',
            ) if /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/;
            $tagInfo{Name} = Image::ExifTool::MakeTagName($tag);
            $tagInfo{List} = 1 if ref $val eq 'ARRAY';
            $tagInfo{Groups}{2} = 'Audio' if $tag =~ /Audio/;
            $tagInfo{Groups}{2} = 'Author' if $tag =~ /(Copyright|Author)/;
            $et->VPrint(0, "  [adding $tag]\n");
            AddTagToTable($tagTablePtr, $tag, \%tagInfo);
        }
        $val = $et->Decode($val, 'UTF8') unless ref $val;
        $et->HandleTag($tagTablePtr, $tag, $val);
        undef $val;
    }
    $$et{INDENT} =~ s/\| $//;
}

#------------------------------------------------------------------------------
# Extract MacOS extended attribute tags
# Inputs: 0) ExifTool object ref, 1) file name
sub ExtractXAttrTags($$)
{
    local $_;
    my ($et, $file) = @_;
    my ($fn, $tag, $val, $warn);

    ($fn = $file) =~ s/([`"\$\\])/\\$1/g;       # escape necessary characters
    $et->VPrint(0, '(running xattr)');
    my @xattr = `xattr -lx "$fn" 2> /dev/null`; # get MacOS extended attributes
    if ($? or not @xattr) {
        $? and $et->Warn('Error running "xattr" to extract XAttr tags');
        return;
    }
    my $tagTablePtr = GetTagTable('Image::ExifTool::MacOS::XAttr');
    $$et{INDENT} .= '| ';
    $et->VerboseDir('XAttr');
    push @xattr, '';    # (for a list terminator)
    foreach (@xattr) {
        chomp;
        if (s/^[\dA-Fa-f]{8}//) {
            $tag or $warn = 1, next;
            s/\|.*//;
            tr/ //d;
            (/[^\dA-Fa-f]/ or length($_) & 1) and $warn = 2, next;
            $val = '' unless defined $val;
            $val .= pack('H*', $_);
            next;
        } elsif ($tag and defined $val) {
            # add to our table if necessary
            unless ($$tagTablePtr{$tag}) {
                my $name;
                # generate tag name from attribute name
                if ($tag =~ /^com\.apple\.(.*)$/) {
                    ($name = $1) =~ s/^metadata:_?k//;
                } else {
                    ($name = $tag) =~ s/[.:]([a-z])/\U$1/g;
                }
                $name = 'XAttr' . ucfirst $name;
                my %tagInfo = ( Name => $name );
                $tagInfo{Groups} = { 2 => 'Time' } if $tag=~/Date$/;
                $et->VPrint(0, "  [adding $tag]\n");
                AddTagToTable($tagTablePtr, $tag, \%tagInfo);
            }
            if ($val =~ /^bplist0/) {
                my %dirInfo = ( DataPt => \$val );
                require Image::ExifTool::PLIST;
                if (Image::ExifTool::PLIST::ProcessBinaryPLIST($et, \%dirInfo, $tagTablePtr)) {
                    next if ref $dirInfo{Value} eq 'HASH';
                    $val = $dirInfo{Value}
                } else {
                    $et->Warn("Error decoding $$tagTablePtr{$tag}{Name}");
                    next;
                }
            }
            if (not ref $val and ($val =~ /\0/ or length($val) > 200) or $tag eq 'XAttrMDLabel') {
                my $buff = $val;
                $val = \$buff;
            }
            $et->HandleTag($tagTablePtr, $tag, $val);
            undef $tag;
            undef $val;
        }
        next unless length;
        s/:$// or $warn = 3, next;  # attribute name must have trailing ":"
        defined $val and $warn = 4, undef $val;
        # remove random ID after kMDLabel in tag ID
        ($tag = $_) =~ s/^com.apple.metadata:kMDLabel_.*/com.apple.metadata:kMDLabel/s;
    }
    $warn and $et->Warn(qq{Error $warn parsing "xattr" output});
    $$et{INDENT} =~ s/\| $//;
}

1;  # end

__END__

=head1 NAME

Image::ExifTool::MacOS - Read/write MacOS system tags

=head1 SYNOPSIS

This module is used by Image::ExifTool

=head1 DESCRIPTION

This module contains definitions required by Image::ExifTool to extract
MDItem* and XAttr* tags on MacOS systems using the "mdls" and "xattr"
utilities respectively.  Writable tags use "xattr", "setfile" or "osascript"
for writing.

=head1 AUTHOR

Copyright 2003-2018, Phil Harvey (phil at owl.phy.queensu.ca)

This library is free software; you can redistribute it and/or modify it
under the same terms as Perl itself.

=head1 SEE ALSO

L<Image::ExifTool::TagNames/MacOS Tags>,
L<Image::ExifTool(3pm)|Image::ExifTool>

=cut