1<?php 2/** 3 * EGroupware - Calendar holidays 4 * 5 * @link http://www.egroupware.org 6 * @package calendar 7 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de> 8 * @copyright (c) 2016 by RalfBecker-At-outdoor-training.de 9 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 10 * @version $Id$ 11 */ 12 13use EGroupware\Api; 14 15/** 16 * Calendar holidays 17 * 18 * Holidays are read from: 19 * - a given iCal URL or 20 * - json file with 2-digit iso country-code: URL pairs is read from https://community.egroupware.org or 21 * - json file is read from /calendar/setup/ical_holiday_urls.json 22 * 23 * Holidays are cached on tree or instance level, later for custom urls. 24 * As fetching and parsing iCal files is expensive, we always render them 25 * from previous (requested) year until next 5 years. 26 * 27 * Holiday urls are from Mozilla Calendar project: 28 * @link https://www.mozilla.org/en-US/projects/calendar/holidays/ 29 * @link https://www.mozilla.org/media/caldata/calendars.json (json from which above page is generated) 30 * @link https://github.com/mozilla/bedrock/tree/master/media/caldata 31 */ 32class calendar_holidays 33{ 34 const URL_CACHE_TIME = 864000; 35 const URL_FAIL_CACHE_TIME = 300; 36 const EGW_HOLIDAY_URL = 'https://community.egroupware.org/egw'; 37 const HOLIDAY_PATH = '/calendar/setup/ical_holiday_urls.json'; 38 const HOLIDAY_CACHE_TIME = 864000; // 10 days 39 40 /** 41 * Read holidays for given country/url and year 42 * 43 * @param string $country 2-digit iso country code or URL 44 * @param int $year =null default current year 45 * @return array of Ymd => array of array with values for keys 'occurence','month','day','name', (commented out) 'title' 46 */ 47 public static function read($country, $year=null) 48 { 49 if (!$year) $year = (int)Api\DateTime::to('now', 'Y'); 50 $level = self::is_url($country) ? Api\Cache::INSTANCE : Api\Cache::TREE; 51 52 $holidays = Api\Cache::getCache($level, __CLASS__, $country.':'.$year); 53 54 // if we dont find holidays in cache, we render from previous year until next 5 years 55 if (!isset($holidays) && ($years = self::render($country, $year-1, $year+5))) 56 { 57 foreach($years as $y => $data) 58 { 59 Api\Cache::setCache($level, __CLASS__, $country.':'.$y, $data, self::HOLIDAY_CACHE_TIME); 60 } 61 $holidays = $years[$year]; 62 } 63 return (array)$holidays; 64 } 65 66 /** 67 * Fetch holiday iCal and convert it to usual holiday format 68 * 69 * @param string $country 2-digit iso country code or URL 70 * @param int $year =null default current year 71 * @param int $until_year =null default, fetch only one year, if given result is indexed additional by year 72 * @return array of Ymd => array of array with values for keys 'occurence','month','day','name', (commented out) 'title' 73 */ 74 public static function render($country, $year=null, $until_year=null) 75 { 76 if (!$year) $year = (int)Api\DateTime::to('now', 'Y'); 77 $end_year = $until_year && $year < $until_year ? $until_year : $year; 78 79 $starttime = microtime(true); 80 if (!($holidays = self::fetch($country))) 81 { 82 return array(); 83 } 84 $years = array(); 85 foreach($holidays as $event) 86 { 87 $start = new Api\DateTime($event['start']); 88 $end = new Api\DateTime($event['end']); 89 if ($start->format('Y') > $end_year) continue; 90 if ($end->format('Y') < $year && !$event['recur_type']) continue; 91 92 // recuring events 93 if ($event['recur_type']) 94 { 95 // calendar_rrule limits no enddate, to 5 years 96 if (!$event['recur_enddate']) $event['recur_enddate'] = (1+$end_year).'0101'; 97 98 $rrule = calendar_rrule::event2rrule($event); 99 if ($rrule->enddate && $rrule->enddate->format('Y') < $year) continue; 100 101 foreach($rrule as $rtime) 102 { 103 if (($y = (int)$rtime->format('Y')) < $year) continue; 104 if ($y > $end_year) break; 105 106 $ymd = (int)$rtime->format('Ymd'); 107 $years[$y][(string)$ymd][] = array( 108 'day' => $ymd % 100, 109 'month' => ($ymd / 100) % 100, 110 'occurence' => $y, 111 'name' => $event['title'], 112 //'title' => $event['description'], 113 ); 114 } 115 } 116 else 117 { 118 $end_ymd = (int)$end->format('Ymd'); 119 while(($ymd = (int)$start->format('Ymd')) <= $end_ymd) 120 { 121 $y = (int)$start->format('Y'); 122 $years[$y][(string)$ymd][] = array( 123 'day' => $ymd % 100, 124 'month' => ($ymd / 100) % 100, 125 'occurence' => $y, 126 'name' => $event['title'], 127 //'title' => $event['description'], 128 ); 129 $start->add('1day'); 130 } 131 } 132 } 133 foreach($years as $y => &$data) 134 { 135 ksort($data); 136 } 137 error_log(__METHOD__."('$country', $year, $end_year) took ". number_format(microtime(true)-$starttime, 3).'s to fetch '.count(call_user_func_array('array_merge', $years)).' events'); 138 unset($starttime); 139 140 return $until_year ? $years : $years[$year]; 141 } 142 143 protected static function is_url($url) 144 { 145 return $url[0] == '/' || strpos($url, '://') !== false; 146 } 147 148 /** 149 * Fetch iCal for given country 150 * 151 * @param string $country 2-digit iso country code or URL 152 * @return array|Iterator parsed events 153 */ 154 protected static function fetch($country) 155 { 156 if (!($url = self::is_url($country) ? $country : self::ical_url($country))) 157 { 158 error_log("No holiday iCal for '$country'!"); 159 return array(); 160 } 161 if (!($f = fopen($url, 'r', false, Api\Framework::proxy_context()))) 162 { 163 error_log("Can NOT open holiday iCal '$url' for country '$country'!"); 164 return array(); 165 } 166 // php does not automatic gzip decode, but it does not accept that in request headers 167 // iCloud eg. always gzip compresses: https://p16-calendars.icloud.com/holidays/au_en-au.ics 168 foreach($http_response_header as $h) 169 { 170 if (preg_match('/^content-encoding:.*gzip/i', $h)) 171 { 172 stream_filter_append($f, 'zlib.inflate', STREAM_FILTER_READ, array('window' => 15|16)); 173 break; 174 } 175 } 176 $parser = new calendar_ical(); 177 if (!($icals = $parser->icaltoegw($f))) 178 { 179 error_log("Error parsing holiday iCal '$url' for country '$country'!"); 180 return array(); 181 } 182 return $icals; 183 } 184 185 /** 186 * Get iCal url for holidays of given country 187 * 188 * We first try to fetch urls from https://community.egroupware.org and if that fails we use the local one. 189 * 190 * @param string $country 191 * @return string|boolean|null string with url, false if we cant load urls, NULL if $country is not included 192 */ 193 protected static function ical_url($country) 194 { 195 $urls = Api\Cache::getTree(__CLASS__, 'ical_holiday_urls'); 196 197 if (!isset($urls)) 198 { 199 if (!($json = file_get_contents(self::EGW_HOLIDAY_URL.self::HOLIDAY_PATH, false, 200 Api\Framework::proxy_context(null, null, array('timeout' => 1))))) 201 { 202 $json = file_get_contents(EGW_SERVER_ROOT.self::HOLIDAY_PATH); 203 } 204 if (!$json || !($urls = json_decode($json, true))) 205 { 206 error_log(__METHOD__."() cant read ical_holiday_urls.json!"); 207 $urls = false; 208 } 209 Api\Cache::setTree(__CLASS__, 'ical_holiday_urls', $urls, $urls ? self::URL_CACHE_TIME : self::URL_FAIL_CACHE_TIME); 210 } 211 return $urls[$country]; 212 } 213 214} 215 216// some tests when url is called direct 217if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__) 218{ 219 $GLOBALS['egw_info'] = array( 220 'flags' => array( 221 'currentapp' => 'login', 222 ) 223 ); 224 include('../../header.inc.php'); 225 226 $country = !empty($_GET['country']) && preg_match('/^[A-Z]{2}$/i', $_GET['country']) ? strtoupper($_GET['country']) : 'DE'; 227 $year = !empty($_GET['year']) && (int)$_GET['year'] > 2000 ? (int)$_GET['year'] : (int)date('Y'); 228 $year_until = !empty($_GET['year_until']) && (int)$_GET['year_until'] >= $year ? (int)$_GET['year_until'] : $year; 229 230 Api\Header\Content::type('holidays-'.$country.'.txt', 'text/plain', 0, true, false); 231 print_r(calendar_holidays::render($country, $year, $year_until)); 232} 233