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