dolibarr/htdocs/core/lib/xcal.lib.php
Laurent Destailleur b8c89a4921 Fix print
2026-04-26 13:32:18 +02:00

639 lines
21 KiB
PHP

<?php
/* Copyright (C) 2008-2011 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2024-2025 MDW <mdeweerd@users.noreply.github.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* \file htdocs/core/lib/xcal.lib.php
* \brief Function to manage calendar files (vcal/ical/...)
*/
/**
* Build a file from an array of events
* All input params and data must be encoded in $conf->charset_output
*
* @param string $format "vcal" or "ical"
* @param string $title Title of export
* @param string $desc Description of export
* @param array<string,WebsitePage|array{uid:string,startdate:int,summary:string,desc:string,url?:?string,author:string,category?:?string,image?:?string,content?:?string}> $events_array Array of events ("uid","startdate","summary","url","desc","author","category","image") or Array of WebsitePage
* @param string $outputfile Output file
* @return int Return integer < 0 if KO, Nb of events in file if OK
*/
function build_calfile($format, $title, $desc, $events_array, $outputfile)
{
dol_syslog("xcal.lib.php::build_calfile Build cal file ".$outputfile." to format ".$format);
if (empty($outputfile)) {
// -1 = error
return -1;
}
$nbevents = 0;
// Note: A cal file is an UTF8 encoded file
$calfileh = fopen($outputfile, "w");
if ($calfileh) {
include_once DOL_DOCUMENT_ROOT."/core/lib/date.lib.php";
$now = dol_now();
$encoding = "";
if ($format === "vcal") {
$encoding = "ENCODING=QUOTED-PRINTABLE:";
}
// Print header
fwrite($calfileh, "BEGIN:VCALENDAR\n");
// version is always "2.0"
fwrite($calfileh, "VERSION:2.0\n");
fwrite($calfileh, "METHOD:PUBLISH\n");
fwrite($calfileh, "PRODID:-//DOLIBARR ".DOL_VERSION."\n");
fwrite($calfileh, "CALSCALE:GREGORIAN\n");
fwrite($calfileh, "X-WR-CALNAME:".$encoding.format_cal($format, $title)."\n");
fwrite($calfileh, "X-WR-CALDESC:".$encoding.format_cal($format, $desc)."\n");
//fwrite($calfileh,"X-WR-TIMEZONE:Europe/Paris\n");
if (getDolGlobalString('MAIN_AGENDA_EXPORT_CACHE') && getDolGlobalInt('MAIN_AGENDA_EXPORT_CACHE') > 60) {
$hh = convertSecondToTime(getDolGlobalInt('MAIN_AGENDA_EXPORT_CACHE'), "hour");
$mm = convertSecondToTime(getDolGlobalInt('MAIN_AGENDA_EXPORT_CACHE'), "min");
$ss = convertSecondToTime(getDolGlobalInt('MAIN_AGENDA_EXPORT_CACHE'), "sec");
fwrite($calfileh, "X-PUBLISHED-TTL: P".$hh."H".$mm."M".$ss."S\n");
}
foreach ($events_array as $key => $event) {
// See http://fr.wikipedia.org/wiki/ICalendar for format
// See http://www.ietf.org/rfc/rfc2445.txt for RFC
// TODO: avoid use extra event array, use objects direct thahtwas created before
$uid = $event["uid"];
$type = $event["type"];
$startdate = $event["startdate"];
$duration = $event["duration"];
$enddate = $event["enddate"];
$summary = $event["summary"];
$category = $event["category"];
$priority = $event["priority"];
$fulldayevent = $event["fulldayevent"];
$location = $event["location"];
$email = $event["email"];
$url = $event["url"];
$transparency = $event["transparency"];
$description = dol_string_nohtmltag(preg_replace("/<br[\s\/]?>/i", "\n", $event["desc"]), 0);
$created = $event["created"];
$modified = $event["modified"];
$assignedUsers = $event["assignedUsers"];
//print $fulldayevent.' '.dol_print_date($startdate, 'dayhour', 'gmt');
// Format
$summary = format_cal($format, $summary);
$description = format_cal($format, $description);
$category = format_cal($format, (string) $category);
$location = format_cal($format, (string) $location);
// Output the vCard/iCal VEVENT object
/*
Example from Google ical export for a 1 hour event:
BEGIN:VEVENT
DTSTART:20101103T120000Z
DTEND:20101103T130000Z
DTSTAMP:20101121T144902Z
UID:4eilllcsq8r1p87ncg7vc8dbpk@google.com
CREATED:20101121T144657Z
DESCRIPTION:
LAST-MODIFIED:20101121T144707Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Tâche 1 heure
TRANSP:OPAQUE
END:VEVENT
Example from Google ical export for a 1 day event:
BEGIN:VEVENT
DTSTART;VALUE=DATE:20101102
DTEND;VALUE=DATE:20101103
DTSTAMP:20101121T144902Z
UID:d09t43kcf1qgapu9efsmmo1m6k@google.com
CREATED:20101121T144607Z
DESCRIPTION:
LAST-MODIFIED:20101121T144607Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Tâche 1 jour
TRANSP:TRANSPARENT
END:VEVENT
*/
if ($type === "event") {
$nbevents++;
fwrite($calfileh, "BEGIN:VEVENT\n");
fwrite($calfileh, "UID:".$uid."\n");
if (!empty($email)) {
fwrite($calfileh, "ORGANIZER:MAILTO:".$email."\n");
fwrite($calfileh, "CONTACT:MAILTO:".$email."\n");
}
if (!empty($url)) {
fwrite($calfileh, "URL:".$url."\n");
}
if (is_array($assignedUsers)) {
foreach ($assignedUsers as $assignedUser) {
if ($assignedUser->email === $email) {
continue;
}
fwrite($calfileh, "ATTENDEE;RSVP=TRUE:mailto:".$assignedUser->email."\n");
}
}
if ($created) {
fwrite($calfileh, "CREATED:".dol_print_date($created, "dayhourxcard", true)."\n");
}
if ($modified) {
fwrite($calfileh, "LAST-MODIFIED:".dol_print_date($modified, "dayhourxcard", true)."\n");
}
fwrite($calfileh, "SUMMARY:".$encoding.$summary."\n");
fwrite($calfileh, "DESCRIPTION:".$encoding.$description."\n");
if (!empty($location)) {
fwrite($calfileh, "LOCATION:".$encoding.$location."\n");
}
if ($fulldayevent) {
fwrite($calfileh, "X-FUNAMBOL-ALLDAY:1\n");
}
// see https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/0f262da6-c5fd-459e-9f18-145eba86b5d2
if ($fulldayevent) {
fwrite($calfileh, "X-MICROSOFT-CDO-ALLDAYEVENT:TRUE\n");
}
// Date must be GMT dates
// Current date
fwrite($calfileh, "DTSTAMP:".dol_print_date($now, "dayhourxcard", 'gmt')."\n");
// Start date
$prefix = "";
$startdatef = dol_print_date($startdate, "dayhourxcard", 'gmt');
if ($fulldayevent) {
// For fullday event, date was stored with old version by using the user timezone instead of storing the date at UTC+0
// in the timezone of server (so for a PHP timezone of -3, we should store '2023-05-31 21:00:00.000'
// Using option MAIN_STORE_FULL_EVENT_IN_GMT=1 change the behaviour to store in GMT for full day event. This must become
// the default behaviour but there is no way to change keeping old saved date compatible.
$tzforfullday = getDolGlobalString('MAIN_STORE_FULL_EVENT_IN_GMT');
// Local time should be used to prevent users in time zones earlier than GMT from being one day earlier
$prefix = ";VALUE=DATE";
if ($tzforfullday) {
$startdatef = dol_print_date($startdate, "dayxcard", 'gmt');
} else {
$startdatef = dol_print_date($startdate, "dayxcard", 'tzserver');
}
}
fwrite($calfileh, "DTSTART".$prefix.":".$startdatef."\n");
// End date
if ($fulldayevent) {
if (empty($enddate)) {
// We add 1 day needed for full day event (DTEND must be next day after event).
// This is mention in https://datatracker.ietf.org/doc/html/rfc5545:
// "The "DTEND" property for a "VEVENT" calendar component specifies the non-inclusive end of the event."
$enddate = dol_time_plus_duree($startdate, 1, "d");
}
} else {
if (empty($enddate)) {
$enddate = $startdate + $duration;
}
}
$prefix = "";
$enddatef = dol_print_date($enddate, "dayhourxcard", 'gmt');
if ($fulldayevent) {
$prefix = ";VALUE=DATE";
// We add 1 second so we reach the +1 day needed for full day event (DTEND must be next day after event)
// This is mention in https://datatracker.ietf.org/doc/html/rfc5545:
// "The "DTEND" property for a "VEVENT" calendar component specifies the non-inclusive end of the event."
$enddatef = dol_print_date($enddate + 1, "dayxcard", 'tzserver');
}
fwrite($calfileh, "DTEND".$prefix.":".$enddatef."\n");
fwrite($calfileh, "STATUS:CONFIRMED\n");
if (!empty($transparency)) {
fwrite($calfileh, "TRANSP:".$transparency."\n");
}
if (!empty($category)) {
fwrite($calfileh, "CATEGORIES:".$encoding.$category."\n");
}
fwrite($calfileh, "END:VEVENT\n");
}
// Output the vCard/iCal VJOURNAL object
if ($type === "journal") {
$nbevents++;
fwrite($calfileh, "BEGIN:VJOURNAL\n");
fwrite($calfileh, "UID:".$uid."\n");
if (!empty($email)) {
fwrite($calfileh, "ORGANIZER:MAILTO:".$email."\n");
fwrite($calfileh, "CONTACT:MAILTO:".$email."\n");
}
if (!empty($url)) {
fwrite($calfileh, "URL:".$url."\n");
}
if ($created) {
fwrite($calfileh, "CREATED:".dol_print_date($created, "dayhourxcard", 'gmt')."\n");
}
if ($modified) {
fwrite($calfileh, "LAST-MODIFIED:".dol_print_date($modified, "dayhourxcard", 'gmt')."\n");
}
fwrite($calfileh, "SUMMARY:".$encoding.$summary."\n");
fwrite($calfileh, "DESCRIPTION:".$encoding.$description."\n");
fwrite($calfileh, "STATUS:CONFIRMED\n");
fwrite($calfileh, "CATEGORIES:".$category."\n");
fwrite($calfileh, "LOCATION:".$location."\n");
fwrite($calfileh, "TRANSP:OPAQUE\n");
fwrite($calfileh, "CLASS:CONFIDENTIAL\n");
fwrite($calfileh, "DTSTAMP:".dol_print_date($startdate, "dayhourxcard", 'gmt')."\n");
fwrite($calfileh, "END:VJOURNAL\n");
}
}
// Footer
fwrite($calfileh, "END:VCALENDAR");
fclose($calfileh);
dolChmod($outputfile);
} else {
dol_syslog("xcal.lib.php::build_calfile Failed to open file ".$outputfile." for writing");
return -2;
}
return $nbevents;
}
/**
* Build a file from an array of events.
* All input data must be encoded in $conf->charset_output
*
* @param string $format "rss"
* @param string $title Title of export
* @param string $desc Description of export
* @param array<WebsitePage|array{uid:string,startdate:int,summary:string,desc:string,url?:?string,author:string,category?:?string,image?:?string,content?:?string}> $events_array Array of events ("uid","startdate","summary","url","desc","author","category","image") or Array of WebsitePage
* @param string $outputfile Output file
* @param string $filter (optional) Filter
* @param string $url Url (If empty, forge URL for agenda RSS export)
* @param string $langcode Language code to show in header
* @param bool $useurlforguid If true, use URL as guid, else use uid as guid. Using URL as guid is better for RSS reader to detect changes of event, but it can cause problem if URL is not stable (for example if it contains a session id or a token that can change).
* @return int Return integer < 0 if KO, Nb of events in file if OK
*/
function build_rssfile($format, $title, $desc, $events_array, $outputfile, $filter = '', $url = '', $langcode = '', $useurlforguid = false)
{
global $langs, $mysoc;
global $dolibarr_main_url_root;
dol_syslog("xcal.lib.php::build_rssfile Build rss file ".$outputfile." to format ".$format);
if (empty($outputfile)) {
// -1 = error
return -1;
}
$nbevents = 0;
$fichier = fopen($outputfile, "w");
if ($fichier) {
// Print header
fwrite($fichier, '<?xml version="1.0" encoding="'.$langs->charset_output.'"?>'."\n");
fwrite($fichier, '<rss version="2.0">'."\n");
fwrite($fichier, "<channel>\n");
fwrite($fichier, "<title>".dol_escape_xml($title)."</title>\n");
fwrite($fichier, "<description>".dol_escape_xml($title)."</description>\n");
if ($langcode) {
fwrite($fichier, "<language>".dol_escape_xml($langcode)."</language>\n");
}
// Define $urlwithroot
$urlwithouturlroot = preg_replace("/".preg_quote(DOL_URL_ROOT, "/")."$/i", "", trim($dolibarr_main_url_root));
$urlwithroot = $urlwithouturlroot.DOL_URL_ROOT; // This is to use external domain name found into config file
//$urlwithroot=DOL_MAIN_URL_ROOT; // This is to use same domain name than current
// Url
if (empty($url)) {
$url = $urlwithroot."/public/agenda/agendaexport.php?format=rss&exportkey=".urlencode(getDolGlobalString('MAIN_AGENDA_XCAL_EXPORTKEY'));
}
//fwrite($fichier, "<link><![CDATA[".$url."]]></link>\n");
include_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php';
fwrite($fichier, "<link>".getRootURLFromURL($url)."</link>\n");
// Image
if (!empty($mysoc->logo_squarred_small)) {
$urlimage = $urlwithroot.'/viewimage.php?cache=1&amp;modulepart=mycompany&amp;file='.urlencode('logos/thumbs/'.$mysoc->logo_squarred_small);
//$urlimage = $GLOBALS['website']->virtualhost
if ($urlimage && (empty($GLOBALS['website']) || preg_match('/'.preg_quote($GLOBALS['website']->virtualhost, '/').'/', $urlwithroot))) {
fwrite($fichier, "<image><url><![CDATA[".$urlimage."]]></url><title>".htmlspecialchars($title)."</title><link><![CDATA[".$url."]]></link></image>\n");
}
}
// Add a tag for some readers (Google / Feed readers) to detect that this is a RSS feed and not a generic XML file
fwrite($fichier, '<atom:link href="'.$url.'" rel="self" type="application/rss+xml"/>'."\n");
foreach ($events_array as $key => $event) {
$eventqualified = true;
if ($filter) {
// TODO Add a filter
$eventqualified = false;
}
if ($eventqualified) {
$nbevents++;
if (is_object($event) && get_class($event) == 'WebsitePage') {
// Convert object WebsitePage into an array $event
$tmpevent = array();
$tmpevent['uid'] = (string) $event->id;
$tmpevent['startdate'] = $event->date_creation;
$tmpevent['summary'] = $event->title;
$tmpevent['url'] = $event->fullpageurl ? $event->fullpageurl : $event->pageurl.'.php';
$tmpevent['author'] = $event->author_alias ? $event->author_alias : 'unknown';
//$tmpevent['category'] = '';
$tmpevent['desc'] = $event->description;
if (!empty($event->image)) {
$tmpevent['image'] = $GLOBALS['website']->virtualhost.'/medias/'.$event->image;
} else {
include_once DOL_DOCUMENT_ROOT.'/core/lib/website.lib.php';
$tmpimage = getImageFromHtmlContent($event->content);
if ($tmpimage) {
if (strpos($tmpimage, '/') === 0) { // If $tmpimage is an absolute path
$tmpevent['image'] = $GLOBALS['website']->virtualhost.$tmpimage;
} elseif (stripos($tmpimage, 'http') === 0) { // If $tmpimage is a full URI
$tmpevent['image'] = $tmpimage;
} else {
$tmpevent['image'] = $GLOBALS['website']->virtualhost.'/medias/'.$tmpimage;
} // TODO If $tmpimage is "data:..."
}
}
$tmpevent['content'] = $event->content;
$event = $tmpevent;
}
$uid = $event["uid"];
$startdate = $event["startdate"];
$summary = $event["summary"];
$description = $event["desc"];
$url = empty($event["url"]) ? '' : $event["url"];
$author = $event["author"];
$category = empty($event["category"]) ? null : $event["category"];
$image = '';
if (!empty($event["image"])) {
$image = $event["image"];
} else {
$reg = array();
// If we found a link into content like <img alt="..." class="..." src="..."
if (!empty($event["content"]) && preg_match('/<img\s*(?:alt="[^"]*"\s*)?(?:class="[^"]*"\s*)?src="([^"]+)"/m', $event["content"], $reg)) {
if (!empty($reg[0])) {
$image = $reg[1];
}
// Convert image "/medias/...." and "/viewimage.php?modulepart=medias&file=(.*)"
if (!empty($GLOBALS['website']->virtualhost)) {
if (preg_match('/^\/medias\//', $image)) {
$image = $GLOBALS['website']->virtualhost.$image;
} elseif (preg_match('/^\/viewimage\.php\?modulepart=medias&[^"]*file=([^&"]+)/', $image, $reg)) {
$image = $GLOBALS['website']->virtualhost.'/medias/'.$reg[1];
}
}
}
}
/* No place inside a RSS
$priority = $event["priority"];
$fulldayevent = $event["fulldayevent"];
$location = $event["location"];
$email = $event["email"];
*/
$description = dol_string_nohtmltag(preg_replace("/<br[\s\/]?>/i", "\n", $event["desc"]), 0);
fwrite($fichier, "<item>\n");
fwrite($fichier, "<title><![CDATA[".$summary."]]></title>\n");
//fwrite($fichier, "<link><![CDATA[".$url."]]></link>\n");
fwrite($fichier, "<link>".$url."</link>\n");
//fwrite($fichier, "<author><![CDATA[".$author."]]></author>\n");
if (!empty($category)) {
fwrite($fichier, "<category><![CDATA[".$category."]]></category>\n");
}
//fwrite($fichier, "<description><![CDATA[".$summary."]]></description>\n");
fwrite($fichier, "<description><![CDATA[");
if (!empty($image)) {
fwrite($fichier, '<p><img class="center" src="'.$image.'"/></p>');
}
if ($description) {
fwrite($fichier, $description);
}
fwrite($fichier, "]]></description>\n");
// Note: We may also add description into content:encoded tag.
fwrite($fichier, "<pubDate>".date("r", $startdate)."</pubDate>\n");
if ($useurlforguid) {
fwrite($fichier, '<guid isPermaLink="true">'.$url.'</guid>'."\n");
} else {
fwrite($fichier, '<guid isPermaLink="false"><![CDATA['.str_pad($uid, 10, "0", STR_PAD_LEFT).']]></guid>'."\n");
}
fwrite($fichier, '<source url="'.$url.'"><![CDATA[Dolibarr]]></source>'."\n");
fwrite($fichier, "</item>\n");
}
}
fwrite($fichier, "</channel>");
fwrite($fichier, "\n");
fwrite($fichier, "</rss>");
fclose($fichier);
dolChmod($outputfile);
}
return $nbevents;
}
/**
* Encode for cal export
*
* @param string $format "vcal" or "ical"
* @param string $string String to encode
* @return string String encoded
*/
function format_cal($format, $string)
{
$newstring = $string;
if ($format === "vcal") {
$newstring = quotedPrintEncode($newstring);
}
if ($format === "ical") {
// Replace new lines chars by "\n"
$newstring = preg_replace("/\r\n/i", "\\n", $newstring);
$newstring = preg_replace("/\n\r/i", "\\n", $newstring);
$newstring = preg_replace("/\n/i", "\\n", $newstring);
// Must not exceed 75 char. Cut with "\r\n"+Space
$newstring = calEncode($newstring);
}
return $newstring;
}
/**
* Cut string after 75 chars. Add CRLF+Space.
* line must be encoded in UTF-8
*
* @param string $line String to convert
* @return string String converted
*/
function calEncode($line)
{
$out = "";
$newpara = "";
// If mb_ functions exists, it"s better to use them
if (function_exists("mb_strlen")) {
$strlength = mb_strlen($line, "UTF-8");
for ($j = 0; $j < $strlength; $j++) {
// Take char at position $j
$char = dol_substr($line, $j, 1, "UTF-8");
if ((mb_strlen($newpara, "UTF-8") + mb_strlen($char, "UTF-8")) >= 75) {
// CRLF + Space for cal
$out .= $newpara."\r\n ";
$newpara = "";
}
$newpara .= $char;
}
$out .= $newpara;
} else {
$strlength = dol_strlen($line);
for ($j = 0; $j < $strlength; $j++) {
// Take char at position $j
$char = substr($line, $j, 1);
if ((dol_strlen($newpara) + dol_strlen($char)) >= 75) {
// CRLF + Space for cal
$out .= $newpara."\r\n ";
$newpara = "";
}
$newpara .= $char;
}
$out .= $newpara;
}
return trim($out);
}
/**
* Encode into vcal format
*
* @param string $str String to convert
* @param int $forcal (optional) 1 = For cal
* @return string String converted
*/
function quotedPrintEncode($str, $forcal = 0)
{
$lines = preg_split("/\r\n/", $str);
$out = "";
foreach ($lines as $line) {
$newpara = "";
// Do not use dol_strlen here, we need number of bytes
$strlength = strlen($line);
for ($j = 0; $j < $strlength; $j++) {
$char = substr($line, $j, 1);
$ascii = ord($char);
if ($ascii < 32 || $ascii === 61 || $ascii > 126) {
$char = "=".strtoupper(sprintf("%02X", $ascii));
}
// Do not use dol_strlen here, we need number of bytes
if ((strlen($newpara) + strlen($char)) >= 76) {
// New line with carray-return (CR) and line-feed (LF)
$out .= $newpara."=\r\n";
// extra space for cal
if ($forcal) {
$out .= " ";
}
$newpara = "";
}
$newpara .= $char;
}
$out .= $newpara;
}
return trim($out);
}
/**
* Decode vcal format
*
* @param string $str String to convert
* @return string String converted
*/
function quotedPrintDecode($str)
{
return trim(quoted_printable_decode(preg_replace("/=\r?\n/", "", $str)));
}