#!/bin/bash
#
# jpegxmp -- set or add a number of common metadata properties to a JPEG file
#
# See --help (the function print-help) for a brief manual.
#
# Requires rdjpgxmp, wrjpgxmp, and xmptool, see
# http://www.w3.org/People/Bos/JPEG-XMP/
#
# Created: 3 November 2008
# Author: Bert Bos


# Dublin Core properties
#
DC="http://purl.org/dc/elements/1.1/"
TITLE="${DC}title"
CREATOR="${DC}creator"
SUBJECT="${DC}subject"
DESCRIPTION="${DC}description"
PUBLISHER="${DC}publisher"
CONTRIBUTOR="${DC}contributor"
DATE="${DC}date"
TYPE="${DC}type"
FORMAT="${DC}format"
IDENTIFIER="${DC}identifier"
RELATION="${DC}relation"
COVERAGE="${DC}coverage"
RIGHTS="${DC}rights"
SOURCE="${DC}source"

# PhotoRDF Technical properties
#
TECH="http://www.w3.org/2000/PhotoRDF/technical-1-0#"
CAMERA="${TECH}camera"
FILM="${TECH}film"
LENS="${TECH}lens"
DEVEL_DATE="${TECH}devel-date"

# Selected EXIF/XMP properties
#
EXIF="http://ns.adobe.com/exif/1.0/"
GPSVersionID="${EXIF}GPSVersionID"	# Value should be 2.0.0.0
GPSLatitude="${EXIF}GPSLatitude"	# DDD,MM,SSk or DDD,MM.mmk
GPSLongitude="${EXIF}GPSLongitude"	# DDD,MM,SSk or DDD,MM.mmk
GPSAltitudeRef="${EXIF}GPSAltitudeRef"	# 0 = above sea level, 1 = below
GPSAltitude="${EXIF}GPSAltitude"	# In meters
GPSBearingRef="${EXIF}GPSDestBearingRef" # T (=True) or M (=Magnetic) North
GPSBearing="${EXIF}GPSDestBearing"	 # Compas direction [0.0,360.0)

# Command line options
#
OPTS="language:,title:,creator:,subject:,description:,publisher:,\
contributor:,date:,identifier:,relation:,coverage:,rights:,camera:,\
film:,lens:,development-date:,latitude:,longitude:,altitude:,\
source:,bearing:,bearingref:,timezone:,\
from-exif,from-name:,add,overwrite,edit,list,help"

# sed option to get extended regexps
#
if sed -r p </dev/null 2>/dev/null; then ext="-r"; else ext="-E"; fi


# die -- print error message and exit
function die
{
  echo "$1" >&2
  exit 1
}


# usage -- print usage message and exit
function usage
{
  echo "Usage: ${0##*/} options JPEG-file [JPEG-file...]"

  echo "where options are: --language= --title= --creator= --subject="
  echo " --description= --publisher= --contributor= --date= --identifier="
  echo " --relation= --coverage= --rights= --camera= --film= --lens="
  echo " --development-date= --source= --latitude= --longitude="
  echo " --altitude= --bearing= --bearingref= --timezone="
  echo " --from-exif --from-name= --add --overwrite --edit --list --help"
  exit 1
}


# print-help -- print a brief usage manual
function print-help
{
  local dq='"' sq="'"

  cat <<-EOD
	SYNOPSIS:

	  ${0##*/} options [--] JPEG-file [JPEG-file...]

	${0##*/} adds or replaces a number of common metadata
	properties in JPEG/JFIF files, such as the creator of the
	image, the location, the date, the copyright, the title and the
	description. The metadata is stored in XMP-format inside the
	image.

	OPTIONS:

	--language=L
	    Set the language for the next arguments to L. Default is
	    ${dq}x-default${dq}. The language should be code from BCP 47
	    (currently equal to RFC 4646), such as ${dq}en${dq} (for English)
	    or ${dq}fr-ca${dq} (for Canadian French).
	--title=T
	    Set the title to T. This should be a (short) text.
	--creator=C
	    Set the creator to C. The creator is the person who took
	    the photo (in the case of a photo) or otherwise made the
	    image.
	--subject=keywords
	    Set the subject. The value should be one or more
	    comma-separated keywords.
	--description=D
	    Set the description to D. This should be a free-form text.
	--publisher=P
	    Set the publisher to P.
	--contributor=C
	    Set the contributor to C. The contributor is somebody who
	    contributed to the image, but is not the creator.
	--date=D
	    Set the date to D. Suggested format: YYYY-MM-DD
	--identifier=I
	    Set the image${sq}s number or identifier to I. This can serve
	    as a unique reference for this image within a certain
	    context, e.g., all images of the same publisher.
	    Typically, multiple copies of the same image have the same
	    identifier, but modified copies (e.g., cropped, resized or
	    digitally processed) have different identifiers. The
	    identifier need not be the same as the file name.
	--relation=R
	    Set the relation between this image and other resources to
	    R. Typically this identifies an event or common theme for
	    a collection of related resources, e.g., ${dq}John${sq}s 17th
	    birthday${dq} or ${dq}Experiment 1703${dq}.
	--coverage=C
	    Set the (spatial) coverage of the image to C. Typically
	    this is the location that is depicted in the image, e.g.,
	    ${dq}Amsterdam, The Netherlands${dq}.
	--rights=R
	    Set the copyright statement to R.
	--camera=C
	    Set the (kind of) camera to C, in case the image is a
	    photo. Typically this is the brand and type, e.g., ${dq}Canon
	    EOS D60${dq}. It may also be a scanner, if the image is scanned.
	--film=F
	    Set the kind of film, in case the image is a photo taken
	    on film. Suggested value for digital photos: ${dq}Digital${dq}
	--lens=L
	    Set the kind of lens to L, in case the image is a photo.
	    This could be the brand and type of the lens.
	--development-date=D
	    Set the date the film was developed, in case the image is
	    a photo taken on film. Suggested format: YYYY-MM-DD
	--source=S
	    The source from which this image is derived.
	--latitude=L
	    The latitude in one of three formats: (a) decimal ("-43.2675")
	    where positive means north; (b) degrees, minutes, seconds and the
	    letter N or S ("43,41,51N"); (c) degrees, minutes with decimals
	    and the letter N or S ("43,41.85N"). Leading zeros on the degrees,
	    minutes or seconds are optional.
	--longitude=L
	    The longitude, in one of three formats: (a) decimal ("-43.2675")
	    where positive means east; (b) degrees, minutes, seconds and the
	    letter E or W ("6,59,3E"); (c) degrees, minutes with decimals and
	    the letter E or W ("6,59.05E").  Leading zeros on the degrees,
	    minutes or seconds are optional.
	--bearing=B
	    Compass direction in which the picture was taken [0.0,360.0).
	--bearingref=[T|M]
	    Reference for --bearing. Must be either "T" (true north) or
	    "M" (magneting north).
	--timezone=tz
	    When reading a date from the EXIF (see --from-exif), add this
	    timezone. Format should be like +02:00 or -05:00, or Z for UTC.
	    This must be set before --from-exif.
	--from-exif
	    Try to copy the date (--date), camera (--camera), and position
	    (--latitude, --longitude, --altitude, --bearing) from the EXIF
	    data in the image, if any.
	--from-name=pattern
	    Set the identifier (--identifier) to a part of the file
	    name, as given by the pattern. The pattern must contain at
	    least one pair of parentheses ${dq}(...)${dq} to indicate which
	    part of the pattern is the identifier. The pattern is
	    implicitly anchored at the start of the file name.
	    Example: --from-name=${dq}([0-9]*)${dq} takes the
	    sequence of digits at the start of the name as the
	    identifier, so that, e.g., the file name
	    ${dq}123456_JohnB.jpg${dq} gets the identifier ${dq}123456${dq}.
	--add
	    Add rather than replace properties. Applies to the
	    following arguments. The properties in the next options
	    will be added, even if there are already values for the
	    same property in the same language. Thus it is possible to
	    give multiple values for some properties. (Some XMP
	    software may not accept multiple values for certain
	    properties.)
	--overwrite
	    Replace rather than add. Applies to the following
	    arguments. If the image already has a property with the
	    same name and same language, it will be replaced. This is
	    the default.
	--edit
	    Extract XMP data, open an editor (\$EDITOR) to edit it,
	    then put the edited data back. Warning: if there are errors in
	    the data after editing, some or all of the data may be
	    lost without warning.
	--list
	    List the known fields from the image (in the given language,
	    if --language precedes --list).
	--help
	    Display this manual.

	All options may occur multiple times. Option names may be
	abbreviated.

	To delete a value, set it to the empty string (only in
	--overwrite mode; adding an empty value in --add mode has no
	effect).

	Tip: the XMP data is always read and written, so running the
	command without any options has the effect of ${dq}prettyprinting${dq}
	the XMP data (and removing any data the program doesn${sq}t
	understand...)

	EOD
  exit
}


# set-field -- set field $1 to value $2 with language $3
function set-field
{
  local h r oldlang

  # Guess that a value that starts with http:/https: is a resource
  case "$2" in
    http:*|https:*) r=-r;;
    *) r=;;
  esac

  # If the language is x-default, remove all other languages.
  if [ "$3" == "x-default" ]; then oldlang='*'; else oldlang=$3; fi

  if [ "$mode" == "add" ]; then
    xmptool -w -l "$3" $r -- "$1" "$2" $TMP1
  elif [ -z "$2" ]; then
    xmptool -d -l "$oldlang" -- "$1" $TMP1
  else
    xmptool -d -l "$oldlang" -- "$1" $TMP1 | xmptool -w -l "$3" $r -- "$1" "$2"
  fi >$TMP2
  h=$TMP1
  TMP1=$TMP2
  TMP2=$h
}


# set-altitude -- set altitude to value $1
function set-altitude
{
  local ref alt

  case "$1" in
    -*) ref=1; alt=${1#-};;
    +*) ref=0; alt=${1#+};;
    *) ref=0; alt=$1;;
  esac
  set-field "$GPSAltitudeRef" "$ref" x-default
  set-field "$GPSAltitude" "$alt" x-default
  set-field "$GPSVersionID" "2.0.0.0" x-default
}


# set-lat-or-long -- set latitude or longitude after checking the syntax
function set-lat-or-long
{
  local d m

  if [ "$2" == "" ]; then
    set-field "$1" "" x-default
  elif [[ "$1" == "$GPSLatitude" ]]; then
    if [[ "$2" =~ ^[0-9]+,[0-9]+,?[0-9]*\.?[0-9]*[NS]$ ]]; then
      # ^[0-9]+,[0-9]+(,[0-9]+)?(\.[0-9]+)?[NS]$
      set-field "$1" "$2" x-default
    elif [[ "$2" =~ ^[0-9]+\.?[0-9]*$ ]]; then
      # ^[0-9]+(\.[0-9]+)?$
      m=$(dc <<<"$2 1%4k60*p"); d=${2%.*}
      set-field "$1" "${d},${m}N" x-default
    elif [[ "$2" =~ ^-[0-9]+\.?[0-9]*$ ]]; then
      # ^-[0-9]+(\.[0-9]+)?$
      m=$(dc <<<"${2#-} 1%4k60*p"); d=${2#-}; d=${d%.*}
      set-field "$1" "${d},${m}S" x-default
    elif [[ "$2" =~ ^[0-9]+,[0-9]*\.[0-9]+[NS]$ ]]; then
      set-field "$1" "$2" x-default
    else
      die "$0: Error: lat/long must be like 6,58,6W or 43,58.1N or -7.956"
    fi
  elif [[ "$1" == "$GPSLongitude" ]]; then
    if [[ "$2" =~ ^[0-9]+,[0-9]+,?[0-9]*\.?[0-9]*[EW]$ ]]; then
      # ^[0-9]+,[0-9]+(,[0-9]+)?(\.[0-9]+)?[EW]$
      set-field "$1" "$2" x-default
    elif [[ "$2" =~ ^[0-9]+\.?[0-9]*$ ]]; then
      # ^[0-9]+(\.[0-9]+)?$
      m=$(dc <<<"$2 1%4k60*p"); d=${2%.*}
      set-field "$1" "${d},${m}E" x-default
    elif [[ "$2" =~ ^-[0-9]+\.?[0-9]*$ ]]; then
      # ^-[0-9]+(\.[0-9]+)?$
      m=$(dc <<<"${2#-} 1%4k60*p"); d=${2#-}; d=${d%.*}
      set-field "$1" "${d},${m}W" x-default
    elif [[ "$2" =~ ^[0-9]+,[0-9]*\.[0-9]+[EW]$ ]]; then
      set-field "$1" "$2" x-default
    else
      die "$0: Error: lat/long must be like 6,58,6W or 43,58.1N or -7.956"
    fi
  else
    die "$0: Cannot happen!"
  fi
  set-field "$GPSVersionID" "2.0.0.0" x-default
}


# set-bearingref -- set bearing reference direction after checking syntax
function set-bearingref
{
  case "$1" in
    "") set-field "$GPSBearingRef" "" x-default;;
    T|t) set-field "$GPSBearingRef" T x-default;;
    M|m) set-field "$GPSBearingRef" M x-default;;
    *) die "$0: Error: --bearingref must be \"T\" or \"M\"";;
  esac
}


# set-bearing -- set bearing after checking syntax
function set-bearing
{
  local n=${1%.*}
  local h=${1#$n}
  local d=1

  if [ "$1" == "" ]; then
    set-field "$GPSBearing" "" x-default
  else
    case "$n" in
      [0-9]|[0-9][0-9]|[0-9][0-9][0-9]) ;;
      *) die "$0: Error Bearing value must be a number";;
    esac
    h=${h#.}
    n=${n}${h}
    while [ ! -z "$h" ]; do
      case "$h" in
	[0-9]*) d=${d}0; h=${h#?};;
	*) die "$0: Error Bearing value must be a number";;
      esac
    done
    set-field "$GPSBearing" $n/$d x-default
  fi
}


# copy_from_exif -- get date and camera from EXIF data in $1
function copy_from_exif
{
  local inf=`jhead -v "$1"`
  local camera=`echo "$inf"|sed -n -e '/^Camera model/{s/[^:]*: *//p;q;}'`
  local date=`echo "$inf"|sed -n -e '/^Date\/Time/{s/[^:]*: *//p;q;}'`
  local latref=`echo "$inf"|sed -n -e '/^ *GPSLatitudeRef *=/{s/[^=]*=//p;q;}'`
  local latitude=`echo "$inf"|sed -n -e '/^ *GPSLatitude *=/{s/[^=]*=//p;q;}'`
  local longref=`echo "$inf"|sed -n -e '/^ *GPSLongitudeRef *=/{s/[^=]*=//p;q;}'`
  local longitude=`echo "$inf"|sed -n -e '/^ *GPSLongitude *=/{s/[^=]*=//p;q;}'`
  local altref=`echo "$inf"|sed -n -e '/^ *GPSAltitudeRef *=/{s/[^=]*=//p;q;}'`
  local altitude=`echo "$inf"|sed -n -e '/^ *GPSAltitude *=/{s/[^=]*=//p;q;}'`
  local bearing=`echo "$inf"|sed -n -e '/^ *GPSDestBearing *=/{s/[^=]*=//p;q;}'`
  local bearingref=`echo "$inf"|sed -n -e '/^ *GPSDestBearingRef *=/{s/[^=]*=//p;q;}'`

  if [ ! -z "$camera" ]; then
    set-field "$CAMERA" "$camera" x-default
  fi
  if [ ! -z "$date" ]; then
    date=${date/:/-}; date=${date/:/-}; date=${date/ /T} # To ISO format
    set-field "$DATE" "$date$timezone" x-default
  fi
  if [ ! -z "$altitude" ]; then
    local a=${altitude%/*} b=${altitude#*/}
    case "$altref" in
      *1*) set-field "$GPSAltitude" -`echo "10k$a $b/p" | dc` x-default;;
      *) set-field "$GPSAltitude" `echo "10k$a $b/p" | dc` x-default;;
    esac
  fi
  if [ ! -z "$latitude" ]; then
    local d1 d2 m1 m2 s1 s2 h=$latitude
    d1=${h%%/*}; h=${h#$d1/}
    d2=${h%%,*}; h=${h#$d2, }
    m1=${h%%/*}; h=${h#$m1/}
    m2=${h%%,*}; h=${h#$m2, }
    s1=${h%%/*}; h=${h#$s1/}
    s2=${h%%,*}
    d=`echo "$d1 $d2/p" | dc`
    m=`echo "10k $m1 $m2 / $s1 $s2 / 60 / + p" | dc`
    set-field "$GPSLatitude" $d,$m${latref//\"/} x-default
  fi
  if [ ! -z "$longitude" ]; then
    local d1 d2 m1 m2 s1 s2 h=$longitude
    d1=${h%%/*}; h=${h#$d1/}
    d2=${h%%,*}; h=${h#$d2, }
    m1=${h%%/*}; h=${h#$m1/}
    m2=${h%%,*}; h=${h#$m2, }
    s1=${h%%/*}; h=${h#$s1/}
    s2=${h%%,*}
    d=`echo "$d1 $d2/p" | dc`
    m=`echo "10k $m1 $m2 / $s1 $s2 / 60 / + p" | dc`
    set-field "$GPSLongitude" $d,$m${longref//\"/} x-default
  fi
  if [ ! -z "$bearing" ]; then
    local n=${bearing%%/*} d=#{bearing##*/}
    set-field "$GPSBearing" `echo "10k $n $d / p" | dc` x-default
  fi
  if [ ! -z "$bearingref" ]; then
    set-field "$GPSBearing" ${bearingref//\"/} x-default
  fi
}


# from-name -- use part of $2 (given by pattern $1) as identifier
function from-name
{
  local ident

  ident=`basename "$2" | sed $ext -e "s/^$1.*/\\1/"`
  set-field "$IDENTIFIER" "$ident" x-default
}


# list-known-fields -- list the known fields in language $1, one per line
function list-known-fields
{
  local f n lang val

  if [ ! "$1" = "x-default" ]; then lang="-l $1"; fi

  for f in $TITLE $CREATOR $SUBJECT $DESCRIPTION $PUBLISHER $CONTRIBUTOR \
    $DATE $IDENTIFIER $RELATION $COVERAGE $RIGHTS $SOURCE $CAMERA $FILM \
    $LENS $DEVEL_DATE $GPSVersionID $GPSLatitude $GPSLongitude \
    $GPSAltitudeRef $GPSAltitude $GPSBearingRef $GPSBearing; do
    val=`xmptool $lang -- $f $TMP1 | tr '\n' ' '`
    if [ ! -z "$val" ]; then
      n=${f#${DC}}
      n=${n#${TECH}}
      n=${n#${EXIF}}
      printf "%-8s	%s\n" "$n" "$val"
    fi
  done
  echo
}


# Main

LC_ALL=C			# Avoid problems with sed
LANG=C				# Avoid problems with sed

# Create some temporary files
#
trap 'rm -f $TMP1 $TMP2' 0
TMP1=`mktemp /tmp/jpegtmp.XXXXXX` || die "Could not create temporary file."
TMP2=`mktemp /tmp/jpegtmp.XXXXXX` || die "Could not create temporary file."

# Check prerequisites
#
JPEG_XMP="http://www.w3.org/People/Bos/JPEG-XMP/"
JHEAD="http://www.sentex.net/~mwandel/jhead/"
if ! type -t xmptool >/dev/null; then die "Need xmptool; see $JPEG_XMP"; fi
if ! type -t rdjpgxmp >/dev/null; then die "Need rdjpgxmp; see $JPEG_XMP"; fi
if ! type -t wrjpgxmp >/dev/null; then die "Need wrjpgxmp; see $JPEG_XMP"; fi
if ! type -t jhead >/dev/null; then die "Need jhead; see $JHEAD"; fi
if getopt -T >/dev/null || [ $? != 4 ]; then die "Need GNU version of getopt"; fi

# Check and normalize the (long) command line options
#
h=`getopt -l $OPTS -q -- "" "$@"`
if [ $? != 0 ]; then usage; fi
eval set -- "$h"

# Special rule for lonely --help
#
if [ "$1" == "--help" ]; then print-help; fi

# Find the file arguments at the end of the options, after "--".
#
declare -i file=1
while [ "${!file}" != "--" ]; do let "file++"; done
let "file++"
if [ $file -gt $# ]; then usage; fi

# Loop over the file arguments
#
while [ $file -le $# ]; do

  # Get the existing XMP data, if any, from the file into a temporary file.
  # This file will be modified and later put back into the image file.
  #
  rdjpgxmp "${!file}" >$TMP1

  # Loop over the option arguments
  #
  language=x-default
  mode=overwrite
  declare -i arg=1
  while [ ${!arg} != "--" ]; do
    case ${!arg} in
      # --language) let "arg++"; language="${!arg}";;
      --language) let "arg++"; h="${!arg}"; language=${h:-x-default};;
      --title) let "arg++"; set-field "$TITLE" "${!arg}" "$language";;
      --creator) let "arg++"; set-field "$CREATOR" "${!arg}" "$language";;
      --subject) let "arg++"; set-field "$SUBJECT" "${!arg}" "$language";;
      --desc*) let "arg++"; set-field "$DESCRIPTION" "${!arg}" "$language";;
      --publisher) let "arg++"; set-field "$PUBLISHER" "${!arg}" "$language";;
      --contr*) let "arg++"; set-field "$CONTRIBUTOR" "${!arg}" "$language";;
      --date) let "arg++"; set-field "$DATE" "${!arg}" x-default;;
      --identif*) let "arg++"; set-field "$IDENTIFIER" "${!arg}" x-default;;
      --relation) let "arg++"; set-field "$RELATION" "${!arg}" "$language";;
      --coverage) let "arg++"; set-field "$COVERAGE" "${!arg}" "$language";;
      --rights) let "arg++"; set-field "$RIGHTS" "${!arg}" "$language";;
      --source) let "arg++"; set-field "$SOURCE" "${!arg}" "$language";;
      --camera) let "arg++"; set-field "$CAMERA" "${!arg}" "$language";;
      --film) let "arg++"; set-field "$FILM" "${!arg}" "$language";;
      --lens) let "arg++"; set-field "$LENS" "${!arg}" "$language";;
      --devel*) let "arg++"; set-field "$DEVEL_DATE" "${!arg}" x-default;;
      --latitude) let "arg++"; set-lat-or-long "$GPSLatitude" "${!arg}";;
      --longitude) let "arg++"; set-lat-or-long "$GPSLongitude" "${!arg}";;
      --altitude) let "arg++"; set-altitude "${!arg}";;
      --bearingref) let "arg++"; set-bearingref "${!arg}";;
      --bearing) let "arg++"; set-bearing "${!arg}";;
      --timezone) let "arg++"; timezone=${!arg};;
      --from-exif) copy_from_exif "${!file}";;
      --from-name) let "arg++"; from-name "${!arg}" "${!file}";;
      --add) mode=add;;
      --overwrite) mode=overwrite;;
      --edit) ${EDITOR:-editor} $TMP1;;
      --list) list-known-fields "$language";;
      --help) print-help;;
      *) die "Bug: cannot happen!";;
    esac
    let "arg++"
  done

  # Add the standard type and format, then write everything back to the image
  #
  xmptool -d "$FORMAT" $TMP1 | xmptool -w "$FORMAT" "image/jpeg" |\
   xmptool -d "$TYPE" | xmptool -w "$TYPE" "image" |\
   xmptool -c | wrjpgxmp "${!file}" >$TMP2 &&\
   mv $TMP2 ${!file} &&\
   chmod go+r ${!file}

  let "file++"
done
