Книга: Сценарии командной оболочки. Linux, OS X и Unix. 2-е издание
Назад: Глава 14. ImageMagick и обработка графических файлов
Дальше: Приложение A. Установка Bash в Windows 10

Глава 15. Дни и даты

Вычисления с датами порой бывают очень запутанными, например, когда нужно выяснить, високосный ли указанный год, сколько дней осталось до Нового года или сколько дней вы прожили. В этой области между Unix-системами, такими как OS X, и системами Linux, с их инструментами GNU, лежит глубокая пропасть. Дэвид Маккензи (David MacKenzie), взявший на себя труд переписать утилиту date для GNU-версии Linux, значительно расширил ее возможности.

Если вы пользуетесь OS X или другой системой, где команда date --version выводит сообщение об ошибке, загрузите комплект основных утилит, в состав которого входит расширенная утилита GNU date (иногда устанавливается как gdate). В OS X это можно выполнить с помощью диспетчера пакетов (не установлен по умолчанию, но установить его легко):

$ brew install coreutils

После установки GNU-версии утилиты date определить, високосный ли указанный год, можно с помощью самой программы, без необходимости путаться в сложных правилах о годах, кратных 4, но не 100, и других:

if [ $( date 12/31/$year +%j ) -eq 366 ]

Иными словами, если последним днем года является 366-й, этот год — високосный.

Еще одна замечательная особенность GNU date — возможность вернуться назад во времени. Стандартная команда date в системе Unix основана на понятии «нулевого времени» или дате эпохи: 00:00:00 по Гринвичу 1 января 1970 года. Хотите узнать что-нибудь о происходившем в 1965? Тогда вам не повезло. К счастью, с тремя остроумными сценариями в этой главе вы сможете в полной мере использовать преимущества GNU date.

№ 99. Определение дня недели в указанную дату в прошлом

Вкратце: в какой день недели вы родились? В какой день недели Нил Армстронг и Базз Олдрин ступили на Луну? Сценарий в листинге 15.1 поможет вам быстро найти ответы на эти типичные вопросы и наглядно продемонстрирует мощь GNU date.

Код

Листинг 15.1. Сценарий dayinpast

#!/bin/bash

# dayinpast -- получая дату, сообщает соответствующий ей день недели.

if [ $# -ne 3 ] ; then

  echo "Usage: $(basename $0) mon day year" >&2

  echo " with just numerical values (ex: 7 7 1776)" >&2

  exit 1

fi

date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.

baddate="$?"                    # И сохранить только возвращаемый код.

if [ ! $baddate ] ; then

  date -d $1/$2/$3 +"That was a %A."

else

  if [ $2 -lt 10 ] ; then

    pattern=" $2[^0-9]"

  else

    pattern="$2[^0-9]"

  fi

  dayofweek="$(ncal $1 $3 | grep "$pattern" | cut -c1-2)"

  case $dayofweek in

    Su ) echo "That was a Sunday.";    ;;

    Mo ) echo "That was a Monday.";    ;;

    Tu ) echo "That was a Tuesday.";   ;;

    We ) echo "That was a Wednesday."; ;;

    Th ) echo "That was a Thursday.";  ;;

    Fr ) echo "That was a Friday.";    ;;

    Sa ) echo "That was a Saturday.";  ;;

  esac

fi

exit 0

Как это работает

Мы так нахваливали GNU date, и знаете почему? А потому, что весь сценарий сводится к к единственной команде в .

До смешного просто.

Если требуемая версия date недоступна, сценарий использует ncal , разновидность простой программы cal, которая представляет указанный месяц в любопытном, он очень полезном (!) формате:

$ ncal 8 1990

    August 1990

Mo     6 13 20 27

Tu     7 14 21 28

We  1  8 15 22 29

Th  2  9 16 23 30

Fr  3 10 17 24 31

Sa  4 11 18 25

Su  5 12 19 26

Когда есть эта информация, для определения дня недели достаточно найти строку с соответствующим днем месяца и преобразовать двухбуквенное сокращение в полное название.

Запуск сценария

Нил Армстронг и Базз Олдрин прилунились на Базе Спокойствия 20 июля 1969 года, и, как сообщают результаты в листинге 15.2, это было воскресенье.

Листинг 15.2. Запуск сценария dayinpast с датой посадки Армстронга и Олдрина на Луну

$ dayinpast 7 20 1969

That was a Sunday.

День «Д» высадки союзнических войск в Нормандии, 6 июня 1944 года:

$ dayinpast 6 6 1944

That was a Tuesday.

А вот еще пример, день принятия Декларации о независимости США, 4 июля 1776 года:

$ dayinpast 7 4 1776

That was a Thursday.

Усовершенствование сценария

Все сценарии в этой главе принимают входные данные в формате месяц день год, но было бы хорошо дать пользователям возможность указывать данные в более привычном им виде, например: месяц/день/год. К счастью, это совсем несложно, и отличной отправной точкой вам послужит сценарий № 3 в главе 1.

№ 100. Вычисление дней между датами

Сколько дней вы живете? Сколько дней прошло с того момента, как ваши родители встретились? Подобные вопросы, связанные с определением разности между датами, возникают часто, но вычислить ответ на них обычно бывает непросто. И снова на помощь нам приходит утилита GNU date.

Идея обоих сценариев, № 100 и № 101, заключается в том, что суммируются дни от первой даты до конца года, дни от начала года до второй даты и число дней во всех промежуточных годах. Один и тот же подход можно использовать для вычисления количества дней, прошедших с некоторой даты в прошлом (этот сценарий), и количества дней, оставшихся до некоторой даты в будущем (сценарий № 101).

Листинг 15.3 довольно длинный. Готовы?

Код

Листинг 15.3. Сценарий daysago

  #!/bin/bash

  # daysago -- получая дату в формате месяц/день/год, вычисляет количество

  #   дней, прошедших от нее до текущего дня, учитывая високосные годы, и пр.

  # Если вы используете Linux, замените "$(which gdate)" на "$(which date)".

  #   Если вы используете OS X, установите пакет coreutils с помощью brew или

  #   из исходного кода, чтобы получить команду gdate.

  date="$(which gdate)"

  function daysInMonth

  {

    case $1 in

      1|3|5|7|8|10|12 ) dim=31 ;; # Постоянное значение

      4|6|9|11        ) dim=30 ;;

      2               ) dim=29 ;; # Зависит от года: високосный/невисокосный

      *               ) dim=-1 ;; # Неизвестный месяц

    esac

  }

  function isleap

  {

    # Возвращает ненулевое значение в $leapyear, если $1 -- високосный год.

    leapyear=$($date -d 12/31/$1 +%j | grep 366)

  }

  #######################

  #### ОСНОВНОЙ БЛОК

  #######################

  if [ $# -ne 3 ] ; then

    echo "Usage: $(basename $0) mon day year"

    echo " with just numerical values (ex: 7 7 1776)"

    exit 1

  fi

  $date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.

  if [ $? -ne 0 ] ; then

    echo "Sorry, but $(basename $0) can't run without GNU date." >&2

    exit 1

  fi

  eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")

  startmon=$1; startday=$2; startyear=$3

  daysInMonth $startmon # Инициализирует глобальную переменную dim.

  if [ $startday -lt 0 -o $startday -gt $dim ] ; then

    echo "Invalid: Month #$startmon only has $dim days." >&2

    exit 1

  fi

  if [ $startmon -eq 2 -a $startday -eq 29 ] ; then

    isleap $startyear

    if [ -z "$leapyear" ] ; then

      echo "Invalid: $startyear wasn't a leap year; February had 28 days." >&2

      exit 1

    fi

  fi

  #######################

  #### ВЫЧИСЛЕНИЕ КОЛИЧЕСТВА ДНЕЙ

  #######################

  #### ДНЕЙ В НАЧАЛЬНОМ ГОДУ

  # Собрать строку формата с начальной датой.

  startdatefmt="$startmon/$startday/$startyear"

  calculate="$((10#$($date -d "12/31/$startyear" +%j))) \

    -$((10#$($date -d $startdatefmt +%j)))"

  daysleftinyear=$(( $calculate ))

  #### ДНЕЙ В ПРОМЕЖУТОЧНЫХ ГОДАХ

  daysbetweenyears=0

  tempyear=$(( $startyear + 1 ))

  while [ $tempyear -lt $thisyear ] ; do

    daysbetweenyears=$(($daysbetweenyears + \

    $((10#$($date -d "12/31/$tempyear" +%j)))))

    tempyear=$(( $tempyear + 1 ))

  done

  #### ДНЕЙ В ТЕКУЩЕМ ГОДУ

  dayofyear=$($date +%j) # Это просто!

  #### ТЕПЕРЬ СЛОЖИТЬ ВСЕ ВМЕСТЕ

  totaldays=$(( $((10#$daysleftinyear)) + \

    $((10#$daysbetweenyears)) + \

    $((10#$dayofyear)) ))

  /bin/echo -n "$totaldays days have elapsed between "

  /bin/echo -n "$startmon/$startday/$startyear "

  echo "and today, day $dayofyear of $thisyear."

  exit 0

Как это работает

Сценарий получился довольно длинным, но не слишком сложным. Функция определения високосного года достаточно простая — она всего лишь проверяет, равно ли количество дней в году 366.

В строке выполняется интересная проверка наличия GNU-версии date в системе перед продолжением работы.

Оператор перенаправления отбрасывает любой вывод и сообщения об ошибках, а возвращаемый код проверяется на неравенство нулю, которое свидетельствует об ошибке при попытке разобрать параметр --version. В OS X, например, устанавливается версия date с минимальными возможностями — она не поддерживает параметра --version и многих других.

Далее остается только выполнить простейшие арифметические операции. Спецификатор %j возвращает номер дня в году, поэтому вычисление дней, оставшихся до конца года от начальной даты выполняется просто: . Суммарное количество дней в промежуточных годах вычисляется в цикле while, в котором очередной год хранится в переменной tempyear.

Наконец, определение числа дней от начала текущего года до текущей даты реализуется проще простого: .

dayofyear=$($date +%j)

Затем остается только сложить полученные дни и вывести результат!

Запуск сценария

Вернемся вновь в листинге 15.4 к историческим датам, рассматривавшимся выше.

Листинг 15.4. Запуск сценария daysago для разных дат

$ daysago 7 20 1969

17106 days have elapsed between 7/20/1969 and today, day 141 of 2016.

$ daysago 6 6 1944

26281 days have elapsed between 6/6/1944 and today, day 141 of 2016.

$ daysago 1 1 2010

2331 days have elapsed between 1/1/2010 and today, day 141 of 2016.

Эти команды были выполнены... Пусть date скажет за нас:

$ date

Fri May 20 13:30:49 UTC 2016

Усовершенствование сценария

Имеется одно ошибочное условие, которое не проверяется сценарием: когда прошлая дата находится лишь в нескольких днях от текущей или даже в будущем. Что получится в таких ситуациях и как исправить эту ошибку? (Совет: загляните в сценарий № 101, где вы найдете дополнительные проверки, которые можно применить в этом сценарии.)

№ 101. Вычисление дней до указанной даты

Логичным дополнением сценария № 100, daysago, является другой сценарий, daysuntil. Он, по сути, выполняет те же вычисления, но сначала подсчитывает количество дней, оставшихся до конца текущего года, затем количество дней в промежуточных годах и количество дней до указанной даты в целевом году, как показано в листинге 15.5.

Код

Листинг 15.5. Сценарий daysuntil

  #!/bin/bash

  # daysuntil -- фактически выполняет те же вычисления, что и daysago,

  #   но в обратном порядке, когда текущая дата становится "датой в прошлом",

  #   а дата в будущем -- "текущей".

  # Так же, как в предыдущем сценарии, используйте 'which gdate’ в OS X

  #   и 'which date' в Linux.

  date="$(which gdate)"

  function daysInMonth

  {

    case $1 in

      1|3|5|7|8|10|12 ) dim=31 ;; # Постоянное значение

      4|6|9|11        ) dim=30 ;;

      2               ) dim=29 ;; # Зависит от года: високосный/невисокосный

      *               ) dim=-1 ;; # Неизвестный месяц

    esac

  }

  function isleap

  {

    # Возвращает ненулевое значение в $leapyear, если $1 -- високосный год.

    leapyear=$($date -d 12/31/$1 +%j | grep 366)

  }

  #######################

  #### ОСНОВНОЙ БЛОК

  #######################

  if [ $# -ne 3 ] ; then

    echo "Usage: $(basename $0) mon day year"

    echo " with just numerical values (ex: 1 1 2020)"

    exit 1

  fi

  $date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.

  if [ $? -ne 0 ] ; then

    echo "Sorry, but $(basename $0) can't run without GNU date." >&2

    exit 1

  fi

  eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")

  endmon=$1; endday=$2; endyear=$3

  # Необходимые проверки параметров...

  daysInMonth $endmon # Инициализирует переменную $dim

  if [ $endday -lt 0 -o $endday -gt $dim ] ; then

    echo "Invalid: Month #$endmon only has $dim days." >&2

    exit 1

  fi

  if [ $endmon -eq 2 -a $endday -eq 29 ] ; then

    isleap $endyear

    if [ -z "$leapyear" ] ; then

      echo "Invalid: $endyear wasn't a leapyear; February had 28 days." >&2

      exit 1

    fi

  fi

  if [ $endyear -lt $thisyear ] ; then

    echo "Invalid: $endmon/$endday/$endyear is prior to the current year." >&2

    exit 1

  fi

  if [ $endyear -eq $thisyear -a $endmon -lt $thismon ] ; then

    echo "Invalid: $endmon/$endday/$endyear is prior to the current month." >&2

    exit 1

  fi

  if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -lt $thisday ]

  then

    echo "Invalid: $endmon/$endday/$endyear is prior to the current date." >&2

    exit 1

  fi

  if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -eq $thisday ]

  then

    echo "There are zero days between $endmon/$endday/$endyear and today." >&2

    exit 0

  fi

  #### Если целевая дата находится в этом же году,

  ####   вычисления должны выполняться немного иначе.

  if [ $endyear -eq $thisyear ] ; then

    totaldays=$(( $($date -d "$endmon/$endday/$endyear" +%j) - $($date +%j) ))

  else

    #### Вычислить количество дней по фрагментам,

    ####   начиная с количества дней до конца текущего года.

    #### ДНЕЙ ОСТАЛОСЬ В НАЧАЛЬНОМ ГОДУ

    # Собрать строку формата с начальной датой.

    thisdatefmt="$thismon/$thisday/$thisyear"

    calculate="$($date -d "12/31/$thisyear" +%j) - $($date -d $thisdatefmt +%j)"

    daysleftinyear=$(( $calculate ))

    #### ДНЕЙ В ПРОМЕЖУТОЧНЫХ ГОДАХ

    daysbetweenyears=0

    tempyear=$(( $thisyear + 1 ))

    while [ $tempyear -lt $endyear ] ; do

      daysbetweenyears=$(( $daysbetweenyears + \

        $($date -d "12/31/$tempyear" +%j) ))

      tempyear=$(( $tempyear + 1 ))

    done

    #### ДНЕЙ В КОНЕЧНОМ ГОДУ

    dayofyear=$($date --date $endmon/$endday/$endyear +%j) # Это просто!

    #### ТЕПЕРЬ СЛОЖИТЬ ВСЕ ВМЕСТЕ

    totaldays=$(( $daysleftinyear + $daysbetweenyears + $dayofyear ))

  fi

  echo "There are $totaldays days until the date $endmon/$endday/$endyear."

  exit 0

Как это работает

Как уже говорилось, этот сценарий во многом повторяет сценарий daysago. Возможно, их стоило бы объединить в один и проверять дату, указанную пользователем, — в прошлом она находится или в будущем. Большая часть вычислений в этом сценарии — просто инверсная версия вычислений в сценарии daysago, в том смысле, что они позволяют заглянуть вперед, в будущее, а не назад, в прошлое.

Однако этот сценарий чуть надежнее, потому что перед фактическими вычислениями проверяет намного больше ошибочных ситуаций. Возьмите, например, нашу любимую проверку в строке .

Если кто-то попытается обхитрить сценарий, указав сегодняшнюю дату, эта проверка определит такую попытку и выведет ответ «zero days» (ноль дней).

Запуск сценария

Сколько дней осталось до 1 января 2020? Листинг 15.6 отвечает на этот вопрос.

Листинг 15.6. Запуск сценария daysuntil для первого дня 2020 года

$ daysuntil 1 1 2020

There are 1321 days until the date 1/1/2020.

Сколько дней осталось до Рождества 2025?

$ daysuntil 12 25 2025

There are 3506 days until the date 12/25/2025.

Готовитесь отметить трехсотлетие Соединенных Штатов? Посмотрите, сколько дней осталось до этой даты:

$ daysuntil 7 4 2076

There are 21960 days until the date 7/4/2076.

Наконец, учитывая следующий результат, есть все шансы, что нас не будет в третьем тысячелетии:

$ daysuntil 1 1 3000

There are 359259 days until the date 1/1/3000.

Усовершенствование сценария

В сценарии № 99 в начале главы мы научились определять, на какой день недели выпадает указанная дата. Было бы очень полезно объединить эту возможность с возможностями сценариев daysago и daysuntil, чтобы сразу получить всю информацию о выбранной дате.

Назад: Глава 14. ImageMagick и обработка графических файлов
Дальше: Приложение A. Установка Bash в Windows 10