@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Calendar Import: calendar uploader is not anymore an alien

Summary:
If one of *your* verified email addresses is invited in *your* ICS Calendar,
you are now imported as yourself, instead of being a "Private User".

For example, if you own a Google Calendar, and if you import that in
Phorge, and if your email is mentioned in the Invitees:

- you are not shown anymore as "Private User 1" but as yourself
- the "Busy" or "Available" badge is shown from your Profile
(instead of nothing), respecting the "Time Transparency"
RFC 5545 section 3.8.2.7 from your ICS event
https://icalendar.org/iCalendar-RFC-5545/3-8-2-7-time-transparency.html
- the widget "Profile Calendar" in your user page shows your imported
Event, instead of nothing. No "Clear Sailing ahead" anymore.
As usual - this happens only if the event happens today, tomorrow,
or the day after tomorrow.

Example situation:

User "test" imports a Calendar. An Event has two invited emails:

- 1 email is verified and belongs to the very same user "test"
- 1 email belongs to another user

| Before | After |
|-----------|-----------|
| {F324892} | {F324893} |

See that the calendar importer named "test" is not an alien anymore.

Allowing to match yourself makes sense because you trust your imported
Calendar file, and we trust your verified email addresses.

WE DO NOT MATCH OTHER USERS BUT THE CALENDAR OWNER.
Matching other users must involve serious privacy measures,
coherent with the rest of Phorge.

Closes T15564
Closes T15941

Test Plan:
Download this example ICS file:

{F2599125}

Replace the email `boz+asdlol@reyboz.it` with one of your verified email of your Phorge account.

Import the event in Phorge using {nav Calendar > Imports > Import Events > Import .ics File}

/calendar/import/edit/?importType=icsuri

Two imported events are created successfully:

- In the event "Very busy opaque" (25 December 2024):
- you are finally shown as "Busy"
- there is also another "Private user"
- In the other event "Very available transparent" (25 December 2024)
- you are finall shown as "Available"
- there is also another "Private user"

Then nuke these example events by visiting the import and "Delete Imported Events".

---

Try again from scratch in these alternatives:

- if you import the ICS file as-is:
- you get two "Private User" in all events (since none of the invitees matches one of your verified emails)
- if you import the ICS file, setting one of your un-verified emails:
- you get two "Private User" in all events (since none of the invitees matches one of your verified emails)
- if you import the ICS file, setting a verified email of *another* user:
- you get two "Private User" in all events (since none of the invitees matches one of your verified emails)

As additional test, from the file you can also manually set these events to today, tomorrow, or the day after tomorrow; so you can test the user profile's calendar widget, and see that finally it does not show "Clear sailing" anymore, but it shows your calendar invitations (if the ICS file contains one of your verified email - as already said):

{F2599178}

Reviewers: O1 Blessed Committers, aklapper

Reviewed By: O1 Blessed Committers, aklapper

Subscribers: aklapper, avivey, speck, tobiaswiese, Matthew, Cigaryno

Maniphest Tasks: T15564, T15941

Differential Revision: https://we.phorge.it/D25363

+170 -16
+3
src/applications/calendar/controller/PhabricatorCalendarEventViewController.php
··· 166 166 } 167 167 168 168 $availability_select->setDropdownMenu($dropdown); 169 + $availability_select->setDisabled($event->isImportedEvent()); 169 170 $header->addActionLink($availability_select); 170 171 } 171 172 ··· 629 630 ->setIcon('fa-times grey') 630 631 ->setHref($this->getApplicationURI("/event/decline/{$id}/")) 631 632 ->setWorkflow(true) 633 + ->setDisabled($event->isImportedEvent()) 632 634 ->setText(pht('Decline')); 633 635 634 636 $accept_button = id(new PHUIButtonView()) ··· 636 638 ->setIcon('fa-check green') 637 639 ->setHref($this->getApplicationURI("/event/accept/{$id}/")) 638 640 ->setWorkflow(true) 641 + ->setDisabled($event->isImportedEvent()) 639 642 ->setText(pht('Accept')); 640 643 641 644 return array($decline_button, $accept_button);
+59 -15
src/applications/calendar/import/PhabricatorCalendarImportEngine.php
··· 207 207 $events = null; 208 208 } 209 209 210 + // Verified emails of the Event Uploader, to be eventually matched. 211 + // Phorge loves privacy, so emails are generally private. 212 + // This just covers a corner case: yourself importing yourself. 213 + // NOTE: We are using the omnipotent user since we already have 214 + // withUserPHIDs() limiting to a specific person (you). 215 + $author_verified_emails = id(new PhabricatorPeopleUserEmailQuery()) 216 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 217 + ->withUserPHIDs(array($import->getAuthorPHID())) 218 + ->withIsVerified(true) 219 + ->execute(); 220 + $author_verified_emails = mpull($author_verified_emails, 'getAddress'); 221 + $author_verified_emails = array_fuse($author_verified_emails); 222 + 210 223 $xactions = array(); 211 224 $update_map = array(); 212 225 $invitee_map = array(); 213 - $attendee_map = array(); 226 + $attendee_name_map = array(); // map[eventUID][email from] = Attendee 227 + $attendee_user_map = array(); // map[eventUID][userPHID ] = Attendee 214 228 foreach ($node_map as $full_uid => $node) { 215 229 $event = idx($events, $full_uid); 216 230 if (!$event) { ··· 227 241 $xactions[$full_uid] = $this->newUpdateTransactions($event, $node); 228 242 $update_map[$full_uid] = $event; 229 243 230 - $attendee_map[$full_uid] = array(); 244 + $attendee_name_map[$full_uid] = array(); 245 + $attendee_user_map[$full_uid] = array(); 231 246 $attendees = $node->getAttendees(); 232 247 $private_index = 1; 233 248 foreach ($attendees as $attendee) { ··· 236 251 // of the product. 237 252 $name = $attendee->getName(); 238 253 if (phutil_nonempty_string($name) && preg_match('/@/', $name)) { 239 - $name = new PhutilEmailAddress($name); 240 - $name = $name->getDisplayName(); 254 + $attendee_mail = new PhutilEmailAddress($name); 255 + $name = $attendee_mail->getDisplayName(); 256 + $address = $attendee_mail->getAddress(); 257 + 258 + // Skip creation of dummy "Private User" if it's me, the uploader. 259 + if ($address && isset($author_verified_emails[$address])) { 260 + $attendee_user_map[$full_uid][$import->getAuthorPHID()] = 261 + $attendee; 262 + continue; 263 + } 241 264 } 242 265 243 266 // If we don't have a name or the name still looks like it's an ··· 247 270 $private_index++; 248 271 } 249 272 250 - $attendee_map[$full_uid][$name] = $attendee; 273 + $attendee_name_map[$full_uid][$name] = $attendee; 251 274 } 252 275 } 253 276 254 277 $attendee_names = array(); 255 - foreach ($attendee_map as $full_uid => $event_attendees) { 278 + foreach ($attendee_name_map as $full_uid => $event_attendees) { 256 279 foreach ($event_attendees as $name => $attendee) { 257 280 $attendee_names[$name] = $attendee; 258 281 } ··· 331 354 332 355 $update_map = array_select_keys($update_map, $insert_order); 333 356 foreach ($update_map as $full_uid => $event) { 334 - $parent_uid = $this->getParentNodeUID($node_map[$full_uid]); 357 + $node = $node_map[$full_uid]; 358 + $parent_uid = $this->getParentNodeUID($node); 335 359 if ($parent_uid) { 336 360 $parent_phid = $update_map[$parent_uid]->getPHID(); 337 361 } else { ··· 356 380 // We're just forcing attendees to the correct values here because 357 381 // transactions intentionally don't let you RSVP for other users. This 358 382 // might need to be turned into a special type of transaction eventually. 359 - $attendees = $attendee_map[$full_uid]; 383 + $attendees_name = $attendee_name_map[$full_uid]; 384 + $attendees_user = $attendee_user_map[$full_uid]; 360 385 $old_map = $event->getInvitees(); 361 386 $old_map = mpull($old_map, null, 'getInviteePHID'); 362 387 388 + $phid_invitees = array(); 389 + foreach ($attendees_name as $name => $attendee) { 390 + $attendee_phid = $external_invitees[$name]->getPHID(); 391 + $phid_invitees[$attendee_phid] = $attendee; 392 + } 393 + foreach ($attendees_user as $phid_user_attendee => $attendee) { 394 + $phid_invitees[$phid_user_attendee] = $attendee; 395 + } 396 + 363 397 $new_map = array(); 364 - foreach ($attendees as $name => $attendee) { 365 - $phid = $external_invitees[$name]->getPHID(); 398 + foreach ($phid_invitees as $phid_invitee => $attendee) { 366 399 367 - $invitee = idx($old_map, $phid); 400 + $invitee = idx($old_map, $phid_invitee); 368 401 if (!$invitee) { 369 402 $invitee = id(new PhabricatorCalendarEventInvitee()) 370 403 ->setEventPHID($event->getPHID()) 371 - ->setInviteePHID($phid) 404 + ->setInviteePHID($phid_invitee) 372 405 ->setInviterPHID($import->getPHID()); 373 406 } 374 407 ··· 381 414 break; 382 415 case PhutilCalendarUserNode::STATUS_INVITED: 383 416 default: 384 - $status = PhabricatorCalendarEventInvitee::STATUS_INVITED; 417 + // Is me importing myself? I'm coming! 418 + if ($phid_invitee === $import->getAuthorPHID()) { 419 + $status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; 420 + } else { 421 + $status = PhabricatorCalendarEventInvitee::STATUS_INVITED; 422 + } 385 423 break; 386 424 } 387 425 $invitee->setStatus($status); 426 + // Import "busy/available", very useful for myself to tell this 427 + // to coworkers. This is probably somehow very un-useful for most 428 + // "Private user(s)", but let's add it for them too since it 429 + // doesn't hurt them. 430 + $invitee->importAvailabilityFromTimeTransparency( 431 + $node->getTimeTransparency()); 388 432 $invitee->save(); 389 433 390 - $new_map[$phid] = $invitee; 434 + $new_map[$phid_invitee] = $invitee; 391 435 } 392 - 436 + // Remove old Invitees if they are not invited anymore. 393 437 foreach ($old_map as $phid => $invitee) { 394 438 if (empty($new_map[$phid])) { 395 439 $invitee->delete();
+1 -1
src/applications/calendar/import/__tests__/CalendarImportTestCase.php
··· 56 56 'fileAuthor' => $lincoln_verified, 57 57 'expectedInvitees' => 3, 58 58 'expectedInviteesTests' => array( 59 - // array($lincoln_verified, true), // Self-invitation. T15564 59 + array($lincoln_verified, true), // Self-invitation. T15564 60 60 array($alice_unverified, false), 61 61 array($alien_unverified, false), 62 62 array($alien_verified, false),
+19
src/applications/calendar/parser/data/PhutilCalendarEventNode.php
··· 15 15 private $modifiedDateTime; 16 16 private $organizer; 17 17 private $attendees = array(); 18 + private $timeTransparency; 18 19 private $recurrenceRule; 19 20 private $recurrenceExceptions = array(); 20 21 private $recurrenceDates = array(); ··· 127 128 128 129 public function addAttendee(PhutilCalendarUserNode $attendee) { 129 130 $this->attendees[] = $attendee; 131 + return $this; 132 + } 133 + 134 + /** 135 + * Get the "time transparency" as described by RFC 5545 3.8.2.7. 136 + * @return string|null 137 + */ 138 + public function getTimeTransparency() { 139 + return $this->timeTransparency; 140 + } 141 + 142 + /** 143 + * Set the "time transparency" as described by RFC 5545 3.8.2.7. 144 + * @param string|null $time_transparency 145 + * @return self 146 + */ 147 + public function setTimeTransparency($time_transparency) { 148 + $this->timeTransparency = $time_transparency; 130 149 return $this; 131 150 } 132 151
+4
src/applications/calendar/parser/ics/PhutilICSParser.php
··· 673 673 $attendee = $this->newAttendeeFromProperty($parameters, $value); 674 674 $node->addAttendee($attendee); 675 675 break; 676 + case 'TRANSP': 677 + $transp = $this->newTextFromProperty($parameters, $value); 678 + $node->setTimeTransparency($transp); 679 + break; 676 680 } 677 681 678 682 }
+11
src/applications/calendar/parser/ics/PhutilICSWriter.php
··· 213 213 } 214 214 } 215 215 216 + // In the future you may want to add export support 217 + // to the "Time Trasparency" field. In case, please tell us why. 218 + // No one needs it at the moment. This is not even persisted 219 + // in the event object, so, this cannot be exported. 220 + // $transp = $event->getTimeTransparency(); 221 + // if ($transp) { 222 + // $properties[] = $this->newTextProperty( 223 + // 'TRANSP', 224 + // $transp); 225 + // } 226 + 216 227 $rrule = $event->getRecurrenceRule(); 217 228 if ($rrule) { 218 229 $properties[] = $this->newRRULEProperty(
+11
src/applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php
··· 93 93 'raw' => 'This is a simple event.', 94 94 ), 95 95 ), 96 + array( 97 + 'name' => 'TRANSP', 98 + 'parameters' => array(), 99 + 'value' => array( 100 + 'type' => 'TEXT', 101 + 'value' => array( 102 + 'OPAQUE', 103 + ), 104 + 'raw' => 'OPAQUE', 105 + ), 106 + ), 96 107 ), 97 108 $event->getAttribute('ics.properties')); 98 109
+1
src/applications/calendar/parser/ics/__tests__/data/simple.ics
··· 8 8 DTEND;TZID=America/Los_Angeles:20160915T100000 9 9 SUMMARY:Simple Event 10 10 DESCRIPTION:This is a simple event. 11 + TRANSP:OPAQUE 11 12 END:VEVENT 12 13 END:VCALENDAR
+27
src/applications/calendar/storage/PhabricatorCalendarEventInvitee.php
··· 69 69 } 70 70 } 71 71 72 + /** 73 + * Import the invitee availability from the Time Transparency 74 + * field in an ICS calendar event as per RFC 5545 section 3.8.2.7. 75 + * @param wild $time_transp Time transparency like 'OPAQUE' 76 + * or 'TRANSPARENT' or null. 77 + * @return void 78 + */ 79 + public function importAvailabilityFromTimeTransparency($time_transp) { 80 + // How to understand RFC 5545 suburbs. Example conversation: 81 + // "Hey dude 82 + // I'm a bit *opaque* on this event so I'm not *transparent*" 83 + // Means: 84 + // "Good morning Sir, 85 + // I'm a bit *busy* on this business so I'm not *available*" 86 + static $transparency_2_availability = array( 87 + 'OPAQUE' => self::AVAILABILITY_BUSY, 88 + 'TRANSPARENT' => self::AVAILABILITY_AVAILABLE, 89 + ); 90 + 91 + // Note that idx($array, $key) likes a null $key. 92 + $availability = idx($transparency_2_availability, $time_transp); 93 + if ($availability) { 94 + $this->setAvailability($availability); 95 + } 96 + } 97 + 98 + 72 99 public static function getAvailabilityMap() { 73 100 return array( 74 101 self::AVAILABILITY_AVAILABLE => array(
+34
src/applications/people/query/PhabricatorPeopleUserEmailQuery.php
··· 5 5 6 6 private $ids; 7 7 private $phids; 8 + private $userPhids; 9 + private $isVerified; 8 10 9 11 public function withIDs(array $ids) { 10 12 $this->ids = $ids; ··· 16 18 return $this; 17 19 } 18 20 21 + /** 22 + * With the specified User PHIDs. 23 + * @param null|array $phids User PHIDs 24 + */ 25 + public function withUserPHIDs(array $phids) { 26 + $this->userPhids = $phids; 27 + return $this; 28 + } 29 + 30 + /** 31 + * With a verified email or not. 32 + * @param bool|null $isVerified 33 + */ 34 + public function withIsVerified($verified) { 35 + $this->isVerified = $verified; 36 + return $this; 37 + } 38 + 19 39 public function newResultObject() { 20 40 return new PhabricatorUserEmail(); 21 41 } ··· 39 59 $conn, 40 60 'email.phid IN (%Ls)', 41 61 $this->phids); 62 + } 63 + 64 + if ($this->userPhids !== null) { 65 + $where[] = qsprintf( 66 + $conn, 67 + 'email.userPHID IN (%Ls)', 68 + $this->userPhids); 69 + } 70 + 71 + if ($this->isVerified !== null) { 72 + $where[] = qsprintf( 73 + $conn, 74 + 'email.isVerified = %d', 75 + (int)$this->isVerified); 42 76 } 43 77 44 78 return $where;