4
* @task mail Sending Mail
5
* @task feed Publishing Feed Stories
6
* @task search Search Index
7
* @task files Integration with Files
9
abstract class PhabricatorApplicationTransactionEditor
10
extends PhabricatorEditor {
12
private $contentSource;
17
private $mentionedPHIDs;
18
private $continueOnNoEffect;
19
private $continueOnMissingFields;
20
private $parentMessageID;
21
private $heraldAdapter;
22
private $heraldTranscript;
24
private $unmentionablePHIDMap = array();
27
private $isHeraldEditor;
28
private $isInverseEdgeEditor;
29
private $actingAsPHID;
30
private $disableEmail;
34
* Get the class name for the application this editor is a part of.
36
* Uninstalling the application will disable the editor.
38
* @return string Editor's application class name.
40
abstract public function getEditorApplicationClass();
44
* Get a description of the objects this editor edits, like "Differential
47
* @return string Human readable description of edited objects.
49
abstract public function getEditorObjectsDescription();
52
public function setActingAsPHID($acting_as_phid) {
53
$this->actingAsPHID = $acting_as_phid;
57
public function getActingAsPHID() {
58
if ($this->actingAsPHID) {
59
return $this->actingAsPHID;
61
return $this->getActor()->getPHID();
66
* When the editor tries to apply transactions that have no effect, should
67
* it raise an exception (default) or drop them and continue?
69
* Generally, you will set this flag for edits coming from "Edit" interfaces,
70
* and leave it cleared for edits coming from "Comment" interfaces, so the
71
* user will get a useful error if they try to submit a comment that does
72
* nothing (e.g., empty comment with a status change that has already been
73
* performed by another user).
75
* @param bool True to drop transactions without effect and continue.
78
public function setContinueOnNoEffect($continue) {
79
$this->continueOnNoEffect = $continue;
83
public function getContinueOnNoEffect() {
84
return $this->continueOnNoEffect;
89
* When the editor tries to apply transactions which don't populate all of
90
* an object's required fields, should it raise an exception (default) or
91
* drop them and continue?
93
* For example, if a user adds a new required custom field (like "Severity")
94
* to a task, all existing tasks won't have it populated. When users
95
* manually edit existing tasks, it's usually desirable to have them provide
96
* a severity. However, other operations (like batch editing just the
97
* owner of a task) will fail by default.
99
* By setting this flag for edit operations which apply to specific fields
100
* (like the priority, batch, and merge editors in Maniphest), these
101
* operations can continue to function even if an object is outdated.
103
* @param bool True to continue when transactions don't completely satisfy
104
* all required fields.
107
public function setContinueOnMissingFields($continue_on_missing_fields) {
108
$this->continueOnMissingFields = $continue_on_missing_fields;
112
public function getContinueOnMissingFields() {
113
return $this->continueOnMissingFields;
118
* Not strictly necessary, but reply handlers ideally set this value to
119
* make email threading work better.
121
public function setParentMessageID($parent_message_id) {
122
$this->parentMessageID = $parent_message_id;
125
public function getParentMessageID() {
126
return $this->parentMessageID;
129
public function getIsNewObject() {
130
return $this->isNewObject;
133
protected function getMentionedPHIDs() {
134
return $this->mentionedPHIDs;
137
public function setIsPreview($is_preview) {
138
$this->isPreview = $is_preview;
142
public function getIsPreview() {
143
return $this->isPreview;
146
public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
147
$this->isInverseEdgeEditor = $is_inverse_edge_editor;
151
public function getIsInverseEdgeEditor() {
152
return $this->isInverseEdgeEditor;
155
public function setIsHeraldEditor($is_herald_editor) {
156
$this->isHeraldEditor = $is_herald_editor;
160
public function getIsHeraldEditor() {
161
return $this->isHeraldEditor;
165
* Prevent this editor from generating email when applying transactions.
167
* @param bool True to disable email.
170
public function setDisableEmail($disable_email) {
171
$this->disableEmail = $disable_email;
175
public function getDisableEmail() {
176
return $this->disableEmail;
179
public function setUnmentionablePHIDMap(array $map) {
180
$this->unmentionablePHIDMap = $map;
184
public function getUnmentionablePHIDMap() {
185
return $this->unmentionablePHIDMap;
188
public function getTransactionTypes() {
191
if ($this->object instanceof PhabricatorSubscribableInterface) {
192
$types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
195
if ($this->object instanceof PhabricatorCustomFieldInterface) {
196
$types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
199
if ($this->object instanceof HarbormasterBuildableInterface) {
200
$types[] = PhabricatorTransactions::TYPE_BUILDABLE;
203
if ($this->object instanceof PhabricatorTokenReceiverInterface) {
204
$types[] = PhabricatorTransactions::TYPE_TOKEN;
207
if ($this->object instanceof PhabricatorProjectInterface) {
208
$types[] = PhabricatorTransactions::TYPE_EDGE;
214
private function adjustTransactionValues(
215
PhabricatorLiskDAO $object,
216
PhabricatorApplicationTransaction $xaction) {
218
if ($xaction->shouldGenerateOldValue()) {
219
$old = $this->getTransactionOldValue($object, $xaction);
220
$xaction->setOldValue($old);
223
$new = $this->getTransactionNewValue($object, $xaction);
224
$xaction->setNewValue($new);
227
private function getTransactionOldValue(
228
PhabricatorLiskDAO $object,
229
PhabricatorApplicationTransaction $xaction) {
230
switch ($xaction->getTransactionType()) {
231
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
232
return array_values($this->subscribers);
233
case PhabricatorTransactions::TYPE_VIEW_POLICY:
234
return $object->getViewPolicy();
235
case PhabricatorTransactions::TYPE_EDIT_POLICY:
236
return $object->getEditPolicy();
237
case PhabricatorTransactions::TYPE_JOIN_POLICY:
238
return $object->getJoinPolicy();
239
case PhabricatorTransactions::TYPE_EDGE:
240
$edge_type = $xaction->getMetadataValue('edge:type');
242
throw new Exception("Edge transaction has no 'edge:type'!");
245
$old_edges = array();
246
if ($object->getPHID()) {
247
$edge_src = $object->getPHID();
249
$old_edges = id(new PhabricatorEdgeQuery())
250
->withSourcePHIDs(array($edge_src))
251
->withEdgeTypes(array($edge_type))
255
$old_edges = $old_edges[$edge_src][$edge_type];
258
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
259
// NOTE: Custom fields have their old value pre-populated when they are
260
// built by PhabricatorCustomFieldList.
261
return $xaction->getOldValue();
262
case PhabricatorTransactions::TYPE_COMMENT:
265
return $this->getCustomTransactionOldValue($object, $xaction);
269
private function getTransactionNewValue(
270
PhabricatorLiskDAO $object,
271
PhabricatorApplicationTransaction $xaction) {
272
switch ($xaction->getTransactionType()) {
273
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
274
return $this->getPHIDTransactionNewValue($xaction);
275
case PhabricatorTransactions::TYPE_VIEW_POLICY:
276
case PhabricatorTransactions::TYPE_EDIT_POLICY:
277
case PhabricatorTransactions::TYPE_JOIN_POLICY:
278
case PhabricatorTransactions::TYPE_BUILDABLE:
279
case PhabricatorTransactions::TYPE_TOKEN:
280
return $xaction->getNewValue();
281
case PhabricatorTransactions::TYPE_EDGE:
282
return $this->getEdgeTransactionNewValue($xaction);
283
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
284
$field = $this->getCustomFieldForTransaction($object, $xaction);
285
return $field->getNewValueFromApplicationTransactions($xaction);
286
case PhabricatorTransactions::TYPE_COMMENT:
289
return $this->getCustomTransactionNewValue($object, $xaction);
293
protected function getCustomTransactionOldValue(
294
PhabricatorLiskDAO $object,
295
PhabricatorApplicationTransaction $xaction) {
296
throw new Exception('Capability not supported!');
299
protected function getCustomTransactionNewValue(
300
PhabricatorLiskDAO $object,
301
PhabricatorApplicationTransaction $xaction) {
302
throw new Exception('Capability not supported!');
305
protected function transactionHasEffect(
306
PhabricatorLiskDAO $object,
307
PhabricatorApplicationTransaction $xaction) {
309
switch ($xaction->getTransactionType()) {
310
case PhabricatorTransactions::TYPE_COMMENT:
311
return $xaction->hasComment();
312
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
313
$field = $this->getCustomFieldForTransaction($object, $xaction);
314
return $field->getApplicationTransactionHasEffect($xaction);
315
case PhabricatorTransactions::TYPE_EDGE:
316
// A straight value comparison here doesn't always get the right
317
// result, because newly added edges aren't fully populated. Instead,
318
// compare the changes in a more granular way.
319
$old = $xaction->getOldValue();
320
$new = $xaction->getNewValue();
322
$old_dst = array_keys($old);
323
$new_dst = array_keys($new);
325
// NOTE: For now, we don't consider edge reordering to be a change.
326
// We have very few order-dependent edges and effectively no order
327
// oriented UI. This might change in the future.
331
if ($old_dst !== $new_dst) {
332
// We've added or removed edges, so this transaction definitely
337
// We haven't added or removed edges, but we might have changed
339
foreach ($old as $key => $old_value) {
340
$new_value = $new[$key];
341
if ($old_value['data'] !== $new_value['data']) {
349
return ($xaction->getOldValue() !== $xaction->getNewValue());
352
protected function shouldApplyInitialEffects(
353
PhabricatorLiskDAO $object,
358
protected function applyInitialEffects(
359
PhabricatorLiskDAO $object,
361
throw new PhutilMethodNotImplementedException();
364
private function applyInternalEffects(
365
PhabricatorLiskDAO $object,
366
PhabricatorApplicationTransaction $xaction) {
368
switch ($xaction->getTransactionType()) {
369
case PhabricatorTransactions::TYPE_BUILDABLE:
370
case PhabricatorTransactions::TYPE_TOKEN:
372
case PhabricatorTransactions::TYPE_VIEW_POLICY:
373
$object->setViewPolicy($xaction->getNewValue());
375
case PhabricatorTransactions::TYPE_EDIT_POLICY:
376
$object->setEditPolicy($xaction->getNewValue());
378
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
379
$field = $this->getCustomFieldForTransaction($object, $xaction);
380
return $field->applyApplicationTransactionInternalEffects($xaction);
383
return $this->applyCustomInternalTransaction($object, $xaction);
386
private function applyExternalEffects(
387
PhabricatorLiskDAO $object,
388
PhabricatorApplicationTransaction $xaction) {
389
switch ($xaction->getTransactionType()) {
390
case PhabricatorTransactions::TYPE_BUILDABLE:
391
case PhabricatorTransactions::TYPE_TOKEN:
393
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
394
$subeditor = id(new PhabricatorSubscriptionsEditor())
396
->setActor($this->requireActor());
398
$old_map = array_fuse($xaction->getOldValue());
399
$new_map = array_fuse($xaction->getNewValue());
401
$subeditor->unsubscribe(
403
array_diff_key($old_map, $new_map)));
405
$subeditor->subscribeExplicit(
407
array_diff_key($new_map, $old_map)));
411
// for the rest of these edits, subscribers should include those just
412
// added as well as those just removed.
413
$subscribers = array_unique(array_merge(
415
$xaction->getOldValue(),
416
$xaction->getNewValue()));
417
$this->subscribers = $subscribers;
420
case PhabricatorTransactions::TYPE_EDGE:
421
if ($this->getIsInverseEdgeEditor()) {
422
// If we're writing an inverse edge transaction, don't actually
423
// do anything. The initiating editor on the other side of the
424
// transaction will take care of the edge writes.
428
$old = $xaction->getOldValue();
429
$new = $xaction->getNewValue();
430
$src = $object->getPHID();
431
$const = $xaction->getMetadataValue('edge:type');
433
$type = PhabricatorEdgeType::getByConstant($const);
434
if ($type->shouldWriteInverseTransactions()) {
435
$this->applyInverseEdgeTransactions(
438
$type->getInverseEdgeConstant());
441
foreach ($new as $dst_phid => $edge) {
442
$new[$dst_phid]['src'] = $src;
445
$editor = new PhabricatorEdgeEditor();
447
foreach ($old as $dst_phid => $edge) {
448
if (!empty($new[$dst_phid])) {
449
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
453
$editor->removeEdge($src, $const, $dst_phid);
456
foreach ($new as $dst_phid => $edge) {
457
if (!empty($old[$dst_phid])) {
458
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
464
'data' => $edge['data'],
467
$editor->addEdge($src, $const, $dst_phid, $data);
472
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
473
$field = $this->getCustomFieldForTransaction($object, $xaction);
474
return $field->applyApplicationTransactionExternalEffects($xaction);
477
return $this->applyCustomExternalTransaction($object, $xaction);
480
protected function applyCustomInternalTransaction(
481
PhabricatorLiskDAO $object,
482
PhabricatorApplicationTransaction $xaction) {
483
$type = $xaction->getTransactionType();
485
"Transaction type '{$type}' is missing an internal apply ".
489
protected function applyCustomExternalTransaction(
490
PhabricatorLiskDAO $object,
491
PhabricatorApplicationTransaction $xaction) {
492
$type = $xaction->getTransactionType();
494
"Transaction type '{$type}' is missing an external apply ".
499
* Fill in a transaction's common values, like author and content source.
501
protected function populateTransaction(
502
PhabricatorLiskDAO $object,
503
PhabricatorApplicationTransaction $xaction) {
505
$actor = $this->getActor();
507
// TODO: This needs to be more sophisticated once we have meta-policies.
508
$xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
510
if ($actor->isOmnipotent()) {
511
$xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
513
$xaction->setEditPolicy($this->getActingAsPHID());
516
$xaction->setAuthorPHID($this->getActingAsPHID());
517
$xaction->setContentSource($this->getContentSource());
518
$xaction->attachViewer($actor);
519
$xaction->attachObject($object);
521
if ($object->getPHID()) {
522
$xaction->setObjectPHID($object->getPHID());
529
protected function applyFinalEffects(
530
PhabricatorLiskDAO $object,
535
public function setContentSource(PhabricatorContentSource $content_source) {
536
$this->contentSource = $content_source;
540
public function setContentSourceFromRequest(AphrontRequest $request) {
541
return $this->setContentSource(
542
PhabricatorContentSource::newFromRequest($request));
545
public function setContentSourceFromConduitRequest(
546
ConduitAPIRequest $request) {
548
$content_source = PhabricatorContentSource::newForSource(
549
PhabricatorContentSource::SOURCE_CONDUIT,
552
return $this->setContentSource($content_source);
555
public function getContentSource() {
556
return $this->contentSource;
559
final public function applyTransactions(
560
PhabricatorLiskDAO $object,
563
$this->object = $object;
564
$this->xactions = $xactions;
565
$this->isNewObject = ($object->getPHID() === null);
567
$this->validateEditParameters($object, $xactions);
569
$actor = $this->requireActor();
571
// NOTE: Some transaction expansion requires that the edited object be
573
foreach ($xactions as $xaction) {
574
$xaction->attachObject($object);
575
$xaction->attachViewer($actor);
578
$xactions = $this->expandTransactions($object, $xactions);
579
$xactions = $this->expandSupportTransactions($object, $xactions);
580
$xactions = $this->combineTransactions($xactions);
582
foreach ($xactions as $xaction) {
583
$xaction = $this->populateTransaction($object, $xaction);
586
$is_preview = $this->getIsPreview();
587
$read_locking = false;
588
$transaction_open = false;
592
$type_map = mgroup($xactions, 'getTransactionType');
593
foreach ($this->getTransactionTypes() as $type) {
594
$type_xactions = idx($type_map, $type, array());
595
$errors[] = $this->validateTransaction($object, $type, $type_xactions);
598
$errors = array_mergev($errors);
600
$continue_on_missing = $this->getContinueOnMissingFields();
601
foreach ($errors as $key => $error) {
602
if ($continue_on_missing && $error->getIsMissingFieldError()) {
603
unset($errors[$key]);
608
throw new PhabricatorApplicationTransactionValidationException($errors);
611
$file_phids = $this->extractFilePHIDs($object, $xactions);
613
if ($object->getID()) {
614
foreach ($xactions as $xaction) {
616
// If any of the transactions require a read lock, hold one and
617
// reload the object. We need to do this fairly early so that the
618
// call to `adjustTransactionValues()` (which populates old values)
619
// is based on the synchronized state of the object, which may differ
620
// from the state when it was originally loaded.
622
if ($this->shouldReadLock($object, $xaction)) {
623
$object->openTransaction();
624
$object->beginReadLocking();
625
$transaction_open = true;
626
$read_locking = true;
633
if ($this->shouldApplyInitialEffects($object, $xactions)) {
634
if (!$transaction_open) {
635
$object->openTransaction();
636
$transaction_open = true;
641
if ($this->shouldApplyInitialEffects($object, $xactions)) {
642
$this->applyInitialEffects($object, $xactions);
645
foreach ($xactions as $xaction) {
646
$this->adjustTransactionValues($object, $xaction);
649
$xactions = $this->filterTransactions($object, $xactions);
653
$object->endReadLocking();
654
$read_locking = false;
656
if ($transaction_open) {
657
$object->killTransaction();
658
$transaction_open = false;
663
// Now that we've merged, filtered, and combined transactions, check for
664
// required capabilities.
665
foreach ($xactions as $xaction) {
666
$this->requireCapabilities($object, $xaction);
669
$xactions = $this->sortTransactions($xactions);
672
$this->loadHandles($xactions);
676
$comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
678
->setActingAsPHID($this->getActingAsPHID())
679
->setContentSource($this->getContentSource());
681
if (!$transaction_open) {
682
$object->openTransaction();
685
foreach ($xactions as $xaction) {
686
$this->applyInternalEffects($object, $xaction);
691
foreach ($xactions as $xaction) {
692
$xaction->setObjectPHID($object->getPHID());
693
if ($xaction->getComment()) {
694
$xaction->setPHID($xaction->generatePHID());
695
$comment_editor->applyEdit($xaction, $xaction->getComment());
702
$this->attachFiles($object, $file_phids);
705
foreach ($xactions as $xaction) {
706
$this->applyExternalEffects($object, $xaction);
709
$xactions = $this->applyFinalEffects($object, $xactions);
712
$object->endReadLocking();
713
$read_locking = false;
716
$object->saveTransaction();
718
// Now that we've completely applied the core transaction set, try to apply
719
// Herald rules. Herald rules are allowed to either take direct actions on
720
// the database (like writing flags), or take indirect actions (like saving
721
// some targets for CC when we generate mail a little later), or return
722
// transactions which we'll apply normally using another Editor.
724
// First, check if *this* is a sub-editor which is itself applying Herald
725
// rules: if it is, stop working and return so we don't descend into
728
// Otherwise, we're not a Herald editor, so process Herald rules (possibly
729
// using a Herald editor to apply resulting transactions) and then send out
730
// mail, notifications, and feed updates about everything.
732
if ($this->getIsHeraldEditor()) {
733
// We are the Herald editor, so stop work here and return the updated
736
} else if ($this->shouldApplyHeraldRules($object, $xactions)) {
737
// We are not the Herald editor, so try to apply Herald rules.
738
$herald_xactions = $this->applyHeraldRules($object, $xactions);
740
if ($herald_xactions) {
741
$xscript_id = $this->getHeraldTranscript()->getID();
742
foreach ($herald_xactions as $herald_xaction) {
743
$herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
746
// NOTE: We're acting as the omnipotent user because rules deal with
747
// their own policy issues. We use a synthetic author PHID (the
748
// Herald application) as the author of record, so that transactions
749
// will render in a reasonable way ("Herald assigned this task ...").
750
$herald_actor = PhabricatorUser::getOmnipotentUser();
751
$herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
753
// TODO: It would be nice to give transactions a more specific source
754
// which points at the rule which generated them. You can figure this
755
// out from transcripts, but it would be cleaner if you didn't have to.
757
$herald_source = PhabricatorContentSource::newForSource(
758
PhabricatorContentSource::SOURCE_HERALD,
761
$herald_editor = newv(get_class($this), array())
762
->setContinueOnNoEffect(true)
763
->setContinueOnMissingFields(true)
764
->setParentMessageID($this->getParentMessageID())
765
->setIsHeraldEditor(true)
766
->setActor($herald_actor)
767
->setActingAsPHID($herald_phid)
768
->setContentSource($herald_source);
770
$herald_xactions = $herald_editor->applyTransactions(
774
// Merge the new transactions into the transaction list: we want to
775
// send email and publish feed stories about them, too.
776
$xactions = array_merge($xactions, $herald_xactions);
780
// Before sending mail or publishing feed stories, reload the object
781
// subscribers to pick up changes caused by Herald (or by other side effects
782
// in various transaction phases).
783
$this->loadSubscribers($object);
785
$this->loadHandles($xactions);
788
if (!$this->getDisableEmail()) {
789
if ($this->shouldSendMail($object, $xactions)) {
790
$mail = $this->sendMail($object, $xactions);
794
if ($this->supportsSearch()) {
795
id(new PhabricatorSearchIndexer())
796
->queueDocumentForIndexing($object->getPHID());
799
if ($this->shouldPublishFeedStory($object, $xactions)) {
802
$mailed = $mail->buildRecipientList();
804
$this->publishFeedStory(
810
$this->didApplyTransactions($xactions);
812
if ($object instanceof PhabricatorCustomFieldInterface) {
813
// Maybe this makes more sense to move into the search index itself? For
814
// now I'm putting it here since I think we might end up with things that
815
// need it to be up to date once the next page loads, but if we don't go
816
// there we we could move it into search once search moves to the daemons.
818
// It now happens in the search indexer as well, but the search indexer is
819
// always daemonized, so the logic above still potentially holds. We could
820
// possibly get rid of this. The major motivation for putting it in the
821
// indexer was to enable reindexing to work.
823
$fields = PhabricatorCustomField::getObjectFields(
825
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
826
$fields->readFieldsFromStorage($object);
827
$fields->rebuildIndexes($object);
833
protected function didApplyTransactions(array $xactions) {
834
// Hook for subclasses.
840
* Determine if the editor should hold a read lock on the object while
841
* applying a transaction.
843
* If the editor does not hold a lock, two editors may read an object at the
844
* same time, then apply their changes without any synchronization. For most
845
* transactions, this does not matter much. However, it is important for some
846
* transactions. For example, if an object has a transaction count on it, both
847
* editors may read the object with `count = 23`, then independently update it
848
* and save the object with `count = 24` twice. This will produce the wrong
849
* state: the object really has 25 transactions, but the count is only 24.
851
* Generally, transactions fall into one of four buckets:
853
* - Append operations: Actions like adding a comment to an object purely
854
* add information to its state, and do not depend on the current object
855
* state in any way. These transactions never need to hold locks.
856
* - Overwrite operations: Actions like changing the title or description
857
* of an object replace the current value with a new value, so the end
858
* state is consistent without a lock. We currently do not lock these
859
* transactions, although we may in the future.
860
* - Edge operations: Edge and subscription operations have internal
861
* synchronization which limits the damage race conditions can cause.
862
* We do not currently lock these transactions, although we may in the
864
* - Update operations: Actions like incrementing a count on an object.
865
* These operations generally should use locks, unless it is not
866
* important that the state remain consistent in the presence of races.
868
* @param PhabricatorLiskDAO Object being updated.
869
* @param PhabricatorApplicationTransaction Transaction being applied.
870
* @return bool True to synchronize the edit with a lock.
872
protected function shouldReadLock(
873
PhabricatorLiskDAO $object,
874
PhabricatorApplicationTransaction $xaction) {
878
private function loadHandles(array $xactions) {
880
foreach ($xactions as $key => $xaction) {
881
$phids[$key] = $xaction->getRequiredHandlePHIDs();
884
$merged = array_mergev($phids);
886
$handles = id(new PhabricatorHandleQuery())
887
->setViewer($this->requireActor())
891
foreach ($xactions as $key => $xaction) {
892
$xaction->setHandles(array_select_keys($handles, $phids[$key]));
896
private function loadSubscribers(PhabricatorLiskDAO $object) {
897
if ($object->getPHID() &&
898
($object instanceof PhabricatorSubscribableInterface)) {
899
$subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
901
$this->subscribers = array_fuse($subs);
903
$this->subscribers = array();
907
private function validateEditParameters(
908
PhabricatorLiskDAO $object,
911
if (!$this->getContentSource()) {
913
'Call setContentSource() before applyTransactions()!');
916
// Do a bunch of sanity checks that the incoming transactions are fresh.
917
// They should be unsaved and have only "transactionType" and "newValue"
920
$types = array_fill_keys($this->getTransactionTypes(), true);
922
assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
923
foreach ($xactions as $xaction) {
924
if ($xaction->getPHID() || $xaction->getID()) {
925
throw new PhabricatorApplicationTransactionStructureException(
928
'You can not apply transactions which already have IDs/PHIDs!'));
930
if ($xaction->getObjectPHID()) {
931
throw new PhabricatorApplicationTransactionStructureException(
934
'You can not apply transactions which already have objectPHIDs!'));
936
if ($xaction->getAuthorPHID()) {
937
throw new PhabricatorApplicationTransactionStructureException(
940
'You can not apply transactions which already have authorPHIDs!'));
942
if ($xaction->getCommentPHID()) {
943
throw new PhabricatorApplicationTransactionStructureException(
946
'You can not apply transactions which already have '.
949
if ($xaction->getCommentVersion() !== 0) {
950
throw new PhabricatorApplicationTransactionStructureException(
953
'You can not apply transactions which already have '.
954
'commentVersions!'));
957
$expect_value = !$xaction->shouldGenerateOldValue();
958
$has_value = $xaction->hasOldValue();
960
if ($expect_value && !$has_value) {
961
throw new PhabricatorApplicationTransactionStructureException(
964
'This transaction is supposed to have an oldValue set, but '.
968
if ($has_value && !$expect_value) {
969
throw new PhabricatorApplicationTransactionStructureException(
972
'This transaction should generate its oldValue automatically, '.
973
'but has already had one set!'));
976
$type = $xaction->getTransactionType();
977
if (empty($types[$type])) {
978
throw new PhabricatorApplicationTransactionStructureException(
981
'Transaction has type "%s", but that transaction type is not '.
982
'supported by this editor (%s).',
989
protected function requireCapabilities(
990
PhabricatorLiskDAO $object,
991
PhabricatorApplicationTransaction $xaction) {
993
if ($this->getIsNewObject()) {
997
$actor = $this->requireActor();
998
switch ($xaction->getTransactionType()) {
999
case PhabricatorTransactions::TYPE_COMMENT:
1000
PhabricatorPolicyFilter::requireCapability(
1003
PhabricatorPolicyCapability::CAN_VIEW);
1005
case PhabricatorTransactions::TYPE_VIEW_POLICY:
1006
PhabricatorPolicyFilter::requireCapability(
1009
PhabricatorPolicyCapability::CAN_EDIT);
1011
case PhabricatorTransactions::TYPE_EDIT_POLICY:
1012
PhabricatorPolicyFilter::requireCapability(
1015
PhabricatorPolicyCapability::CAN_EDIT);
1017
case PhabricatorTransactions::TYPE_JOIN_POLICY:
1018
PhabricatorPolicyFilter::requireCapability(
1021
PhabricatorPolicyCapability::CAN_EDIT);
1026
private function buildSubscribeTransaction(
1027
PhabricatorLiskDAO $object,
1031
if (!($object instanceof PhabricatorSubscribableInterface)) {
1035
$texts = array_mergev($blocks);
1036
$phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
1040
$this->mentionedPHIDs = $phids;
1042
if ($object->getPHID()) {
1043
// Don't try to subscribe already-subscribed mentions: we want to generate
1044
// a dialog about an action having no effect if the user explicitly adds
1045
// existing CCs, but not if they merely mention existing subscribers.
1046
$phids = array_diff($phids, $this->subscribers);
1049
foreach ($phids as $key => $phid) {
1050
if ($object->isAutomaticallySubscribed($phid)) {
1051
unset($phids[$key]);
1054
$phids = array_values($phids);
1060
$xaction = newv(get_class(head($xactions)), array());
1061
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
1062
$xaction->setNewValue(array('+' => $phids));
1067
protected function getRemarkupBlocksFromTransaction(
1068
PhabricatorApplicationTransaction $transaction) {
1069
return $transaction->getRemarkupBlocks();
1072
protected function mergeTransactions(
1073
PhabricatorApplicationTransaction $u,
1074
PhabricatorApplicationTransaction $v) {
1076
$type = $u->getTransactionType();
1079
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
1080
return $this->mergePHIDOrEdgeTransactions($u, $v);
1081
case PhabricatorTransactions::TYPE_EDGE:
1082
$u_type = $u->getMetadataValue('edge:type');
1083
$v_type = $v->getMetadataValue('edge:type');
1084
if ($u_type == $v_type) {
1085
return $this->mergePHIDOrEdgeTransactions($u, $v);
1090
// By default, do not merge the transactions.
1095
* Optionally expand transactions which imply other effects. For example,
1096
* resigning from a revision in Differential implies removing yourself as
1099
private function expandTransactions(
1100
PhabricatorLiskDAO $object,
1104
foreach ($xactions as $xaction) {
1105
foreach ($this->expandTransaction($object, $xaction) as $expanded) {
1106
$results[] = $expanded;
1113
protected function expandTransaction(
1114
PhabricatorLiskDAO $object,
1115
PhabricatorApplicationTransaction $xaction) {
1116
return array($xaction);
1120
private function expandSupportTransactions(
1121
PhabricatorLiskDAO $object,
1123
$this->loadSubscribers($object);
1125
$xactions = $this->applyImplicitCC($object, $xactions);
1128
foreach ($xactions as $key => $xaction) {
1129
$blocks[$key] = $this->getRemarkupBlocksFromTransaction($xaction);
1132
$subscribe_xaction = $this->buildSubscribeTransaction(
1136
if ($subscribe_xaction) {
1137
$xactions[] = $subscribe_xaction;
1140
// TODO: For now, this is just a placeholder.
1141
$engine = PhabricatorMarkupEngine::getEngine('extract');
1142
$engine->setConfig('viewer', $this->requireActor());
1144
$block_xactions = $this->expandRemarkupBlockTransactions(
1150
foreach ($block_xactions as $xaction) {
1151
$xactions[] = $xaction;
1157
private function expandRemarkupBlockTransactions(
1158
PhabricatorLiskDAO $object,
1161
PhutilMarkupEngine $engine) {
1163
$block_xactions = $this->expandCustomRemarkupBlockTransactions(
1169
$mentioned_phids = array();
1170
foreach ($blocks as $key => $xaction_blocks) {
1171
foreach ($xaction_blocks as $block) {
1172
$engine->markupText($block);
1173
$mentioned_phids += $engine->getTextMetadata(
1174
PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
1179
if (!$mentioned_phids) {
1180
return $block_xactions;
1183
if ($object instanceof PhabricatorProjectInterface) {
1184
$phids = $mentioned_phids;
1185
$project_type = PhabricatorProjectProjectPHIDType::TYPECONST;
1186
foreach ($phids as $key => $phid) {
1187
if (phid_get_type($phid) != $project_type) {
1188
unset($phids[$key]);
1193
$edge_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
1194
$block_xactions[] = newv(get_class(head($xactions)), array())
1195
->setIgnoreOnNoEffect(true)
1196
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
1197
->setMetadataValue('edge:type', $edge_type)
1198
->setNewValue(array('+' => $phids));
1202
$mentioned_objects = id(new PhabricatorObjectQuery())
1203
->setViewer($this->getActor())
1204
->withPHIDs($mentioned_phids)
1207
$mentionable_phids = array();
1208
foreach ($mentioned_objects as $mentioned_object) {
1209
if ($mentioned_object instanceof PhabricatorMentionableInterface) {
1210
$mentioned_phid = $mentioned_object->getPHID();
1211
if (idx($this->getUnmentionablePHIDMap(), $mentioned_phid)) {
1214
// don't let objects mention themselves
1215
if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
1218
$mentionable_phids[$mentioned_phid] = $mentioned_phid;
1221
if ($mentionable_phids) {
1222
$edge_type = PhabricatorObjectMentionsObject::EDGECONST;
1223
$block_xactions[] = newv(get_class(head($xactions)), array())
1224
->setIgnoreOnNoEffect(true)
1225
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
1226
->setMetadataValue('edge:type', $edge_type)
1227
->setNewValue(array('+' => $mentionable_phids));
1230
return $block_xactions;
1233
protected function expandCustomRemarkupBlockTransactions(
1234
PhabricatorLiskDAO $object,
1237
PhutilMarkupEngine $engine) {
1243
* Attempt to combine similar transactions into a smaller number of total
1244
* transactions. For example, two transactions which edit the title of an
1245
* object can be merged into a single edit.
1247
private function combineTransactions(array $xactions) {
1248
$stray_comments = array();
1252
foreach ($xactions as $key => $xaction) {
1253
$type = $xaction->getTransactionType();
1254
if (isset($types[$type])) {
1255
foreach ($types[$type] as $other_key) {
1256
$merged = $this->mergeTransactions($result[$other_key], $xaction);
1258
$result[$other_key] = $merged;
1260
if ($xaction->getComment() &&
1261
($xaction->getComment() !== $merged->getComment())) {
1262
$stray_comments[] = $xaction->getComment();
1265
if ($result[$other_key]->getComment() &&
1266
($result[$other_key]->getComment() !== $merged->getComment())) {
1267
$stray_comments[] = $result[$other_key]->getComment();
1270
// Move on to the next transaction.
1275
$result[$key] = $xaction;
1276
$types[$type][] = $key;
1279
// If we merged any comments away, restore them.
1280
foreach ($stray_comments as $comment) {
1281
$xaction = newv(get_class(head($result)), array());
1282
$xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
1283
$xaction->setComment($comment);
1284
$result[] = $xaction;
1287
return array_values($result);
1290
protected function mergePHIDOrEdgeTransactions(
1291
PhabricatorApplicationTransaction $u,
1292
PhabricatorApplicationTransaction $v) {
1294
$result = $u->getNewValue();
1295
foreach ($v->getNewValue() as $key => $value) {
1296
if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
1297
if (empty($result[$key])) {
1298
$result[$key] = $value;
1300
// We're merging two lists of edge adds, sets, or removes. Merge
1301
// them by merging individual PHIDs within them.
1302
$merged = $result[$key];
1304
foreach ($value as $dst => $v_spec) {
1305
if (empty($merged[$dst])) {
1306
$merged[$dst] = $v_spec;
1308
// Two transactions are trying to perform the same operation on
1309
// the same edge. Normalize the edge data and then merge it. This
1310
// allows transactions to specify how data merges execute in a
1313
$u_spec = $merged[$dst];
1315
if (!is_array($u_spec)) {
1316
$u_spec = array('dst' => $u_spec);
1318
if (!is_array($v_spec)) {
1319
$v_spec = array('dst' => $v_spec);
1322
$ux_data = idx($u_spec, 'data', array());
1323
$vx_data = idx($v_spec, 'data', array());
1325
$merged_data = $this->mergeEdgeData(
1326
$u->getMetadataValue('edge:type'),
1330
$u_spec['data'] = $merged_data;
1331
$merged[$dst] = $u_spec;
1335
$result[$key] = $merged;
1338
$result[$key] = array_merge($value, idx($result, $key, array()));
1341
$u->setNewValue($result);
1343
// When combining an "ignore" transaction with a normal transaction, make
1344
// sure we don't propagate the "ignore" flag.
1345
if (!$v->getIgnoreOnNoEffect()) {
1346
$u->setIgnoreOnNoEffect(false);
1352
protected function mergeEdgeData($type, array $u, array $v) {
1356
protected function getPHIDTransactionNewValue(
1357
PhabricatorApplicationTransaction $xaction) {
1359
$old = array_fuse($xaction->getOldValue());
1361
$new = $xaction->getNewValue();
1362
$new_add = idx($new, '+', array());
1364
$new_rem = idx($new, '-', array());
1366
$new_set = idx($new, '=', null);
1367
if ($new_set !== null) {
1368
$new_set = array_fuse($new_set);
1373
throw new Exception(
1374
"Invalid 'new' value for PHID transaction. Value should contain only ".
1375
"keys '+' (add PHIDs), '-' (remove PHIDs) and '=' (set PHIDS).");
1380
foreach ($old as $phid) {
1381
if ($new_set !== null && empty($new_set[$phid])) {
1384
$result[$phid] = $phid;
1387
if ($new_set !== null) {
1388
foreach ($new_set as $phid) {
1389
$result[$phid] = $phid;
1393
foreach ($new_add as $phid) {
1394
$result[$phid] = $phid;
1397
foreach ($new_rem as $phid) {
1398
unset($result[$phid]);
1401
return array_values($result);
1404
protected function getEdgeTransactionNewValue(
1405
PhabricatorApplicationTransaction $xaction) {
1407
$new = $xaction->getNewValue();
1408
$new_add = idx($new, '+', array());
1410
$new_rem = idx($new, '-', array());
1412
$new_set = idx($new, '=', null);
1416
throw new Exception(
1417
"Invalid 'new' value for Edge transaction. Value should contain only ".
1418
"keys '+' (add edges), '-' (remove edges) and '=' (set edges).");
1421
$old = $xaction->getOldValue();
1423
$lists = array($new_set, $new_add, $new_rem);
1424
foreach ($lists as $list) {
1425
$this->checkEdgeList($list);
1429
foreach ($old as $dst_phid => $edge) {
1430
if ($new_set !== null && empty($new_set[$dst_phid])) {
1433
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
1439
if ($new_set !== null) {
1440
foreach ($new_set as $dst_phid => $edge) {
1441
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
1448
foreach ($new_add as $dst_phid => $edge) {
1449
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
1455
foreach ($new_rem as $dst_phid => $edge) {
1456
unset($result[$dst_phid]);
1462
private function checkEdgeList($list) {
1466
foreach ($list as $key => $item) {
1467
if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
1468
throw new Exception(
1469
"Edge transactions must have destination PHIDs as in edge ".
1470
"lists (found key '{$key}').");
1472
if (!is_array($item) && $item !== $key) {
1473
throw new Exception(
1474
"Edge transactions must have PHIDs or edge specs as values ".
1475
"(found value '{$item}').");
1480
private function normalizeEdgeTransactionValue(
1481
PhabricatorApplicationTransaction $xaction,
1485
if (!is_array($edge)) {
1486
if ($edge != $dst_phid) {
1487
throw new Exception(
1489
'Transaction edge data must either be the edge PHID or an edge '.
1490
'specification dictionary.'));
1494
foreach ($edge as $key => $value) {
1501
case 'dateModified':
1506
throw new Exception(
1508
'Transaction edge specification contains unexpected key '.
1515
$edge['dst'] = $dst_phid;
1517
$edge_type = $xaction->getMetadataValue('edge:type');
1518
if (empty($edge['type'])) {
1519
$edge['type'] = $edge_type;
1521
if ($edge['type'] != $edge_type) {
1522
$this_type = $edge['type'];
1523
throw new Exception(
1524
"Edge transaction includes edge of type '{$this_type}', but ".
1525
"transaction is of type '{$edge_type}'. Each edge transaction must ".
1526
"alter edges of only one type.");
1530
if (!isset($edge['data'])) {
1531
$edge['data'] = array();
1537
protected function sortTransactions(array $xactions) {
1541
// Move bare comments to the end, so the actions precede them.
1542
foreach ($xactions as $xaction) {
1543
$type = $xaction->getTransactionType();
1544
if ($type == PhabricatorTransactions::TYPE_COMMENT) {
1551
return array_values(array_merge($head, $tail));
1555
protected function filterTransactions(
1556
PhabricatorLiskDAO $object,
1559
$type_comment = PhabricatorTransactions::TYPE_COMMENT;
1561
$no_effect = array();
1562
$has_comment = false;
1563
$any_effect = false;
1564
foreach ($xactions as $key => $xaction) {
1565
if ($this->transactionHasEffect($object, $xaction)) {
1566
if ($xaction->getTransactionType() != $type_comment) {
1569
} else if ($xaction->getIgnoreOnNoEffect()) {
1570
unset($xactions[$key]);
1572
$no_effect[$key] = $xaction;
1574
if ($xaction->hasComment()) {
1575
$has_comment = true;
1583
if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
1584
throw new PhabricatorApplicationTransactionNoEffectException(
1590
if (!$any_effect && !$has_comment) {
1591
// If we only have empty comment transactions, just drop them all.
1595
foreach ($no_effect as $key => $xaction) {
1596
if ($xaction->getComment()) {
1597
$xaction->setTransactionType($type_comment);
1598
$xaction->setOldValue(null);
1599
$xaction->setNewValue(null);
1601
unset($xactions[$key]);
1610
* Hook for validating transactions. This callback will be invoked for each
1611
* available transaction type, even if an edit does not apply any transactions
1612
* of that type. This allows you to raise exceptions when required fields are
1613
* missing, by detecting that the object has no field value and there is no
1614
* transaction which sets one.
1616
* @param PhabricatorLiskDAO Object being edited.
1617
* @param string Transaction type to validate.
1618
* @param list<PhabricatorApplicationTransaction> Transactions of given type,
1619
* which may be empty if the edit does not apply any transactions of the
1621
* @return list<PhabricatorApplicationTransactionValidationError> List of
1622
* validation errors.
1624
protected function validateTransaction(
1625
PhabricatorLiskDAO $object,
1631
case PhabricatorTransactions::TYPE_VIEW_POLICY:
1632
$errors[] = $this->validatePolicyTransaction(
1636
PhabricatorPolicyCapability::CAN_VIEW);
1638
case PhabricatorTransactions::TYPE_EDIT_POLICY:
1639
$errors[] = $this->validatePolicyTransaction(
1643
PhabricatorPolicyCapability::CAN_EDIT);
1645
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
1647
foreach ($xactions as $xaction) {
1648
$groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
1651
$field_list = PhabricatorCustomField::getObjectFields(
1653
PhabricatorCustomField::ROLE_EDIT);
1654
$field_list->setViewer($this->getActor());
1656
$role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
1657
foreach ($field_list->getFields() as $field) {
1658
if (!$field->shouldEnableForRole($role_xactions)) {
1661
$errors[] = $field->validateApplicationTransactions(
1664
idx($groups, $field->getFieldKey(), array()));
1669
return array_mergev($errors);
1672
private function validatePolicyTransaction(
1673
PhabricatorLiskDAO $object,
1678
$actor = $this->requireActor();
1680
// Note $this->xactions is necessary; $xactions is $this->xactions of
1681
// $transaction_type
1682
$policy_object = $this->adjustObjectForPolicyChecks(
1686
// Make sure the user isn't editing away their ability to $capability this
1688
foreach ($xactions as $xaction) {
1690
PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
1694
$xaction->getNewValue());
1695
} catch (PhabricatorPolicyException $ex) {
1696
$errors[] = new PhabricatorApplicationTransactionValidationError(
1700
'You can not select this %s policy, because you would no longer '.
1701
'be able to %s the object.',
1708
if ($this->getIsNewObject()) {
1710
$has_capability = PhabricatorPolicyFilter::hasCapability(
1714
if (!$has_capability) {
1715
$errors[] = new PhabricatorApplicationTransactionValidationError(
1718
pht('The selected %s policy excludes you. Choose a %s policy '.
1719
'which allows you to %s the object.',
1730
protected function adjustObjectForPolicyChecks(
1731
PhabricatorLiskDAO $object,
1734
return clone $object;
1738
* Check for a missing text field.
1740
* A text field is missing if the object has no value and there are no
1741
* transactions which set a value, or if the transactions remove the value.
1742
* This method is intended to make implementing @{method:validateTransaction}
1745
* $missing = $this->validateIsEmptyTextField(
1746
* $object->getName(),
1749
* This will return `true` if the net effect of the object and transactions
1750
* is an empty field.
1752
* @param wild Current field value.
1753
* @param list<PhabricatorApplicationTransaction> Transactions editing the
1755
* @return bool True if the field will be an empty text field after edits.
1757
protected function validateIsEmptyTextField($field_value, array $xactions) {
1758
if (strlen($field_value) && empty($xactions)) {
1762
if ($xactions && strlen(last($xactions)->getNewValue())) {
1770
/* -( Implicit CCs )------------------------------------------------------- */
1774
* When a user interacts with an object, we might want to add them to CC.
1776
final public function applyImplicitCC(
1777
PhabricatorLiskDAO $object,
1780
if (!($object instanceof PhabricatorSubscribableInterface)) {
1781
// If the object isn't subscribable, we can't CC them.
1785
$actor_phid = $this->getActingAsPHID();
1787
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
1788
if (phid_get_type($actor_phid) != $type_user) {
1789
// Transactions by application actors like Herald, Harbormaster and
1790
// Diffusion should not CC the applications.
1794
if ($object->isAutomaticallySubscribed($actor_phid)) {
1795
// If they're auto-subscribed, don't CC them.
1800
foreach ($xactions as $xaction) {
1801
if ($this->shouldImplyCC($object, $xaction)) {
1808
// Only some types of actions imply a CC (like adding a comment).
1812
if ($object->getPHID()) {
1813
if (isset($this->subscribers[$actor_phid])) {
1814
// If the user is already subscribed, don't implicitly CC them.
1818
$unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
1820
PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER);
1821
$unsub = array_fuse($unsub);
1822
if (isset($unsub[$actor_phid])) {
1823
// If the user has previously unsubscribed from this object explicitly,
1824
// don't implicitly CC them.
1829
$xaction = newv(get_class(head($xactions)), array());
1830
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
1831
$xaction->setNewValue(array('+' => array($actor_phid)));
1833
array_unshift($xactions, $xaction);
1838
protected function shouldImplyCC(
1839
PhabricatorLiskDAO $object,
1840
PhabricatorApplicationTransaction $xaction) {
1842
return $xaction->isCommentTransaction();
1846
/* -( Sending Mail )------------------------------------------------------- */
1852
protected function shouldSendMail(
1853
PhabricatorLiskDAO $object,
1862
protected function sendMail(
1863
PhabricatorLiskDAO $object,
1866
// Check if any of the transactions are visible. If we don't have any
1867
// visible transactions, don't send the mail.
1869
$any_visible = false;
1870
foreach ($xactions as $xaction) {
1871
if (!$xaction->shouldHideForMail($xactions)) {
1872
$any_visible = true;
1877
if (!$any_visible) {
1881
$email_to = array_filter(array_unique($this->getMailTo($object)));
1882
$email_cc = array_filter(array_unique($this->getMailCC($object)));
1884
$phids = array_merge($email_to, $email_cc);
1885
$handles = id(new PhabricatorHandleQuery())
1886
->setViewer($this->requireActor())
1890
$template = $this->buildMailTemplate($object);
1891
$body = $this->buildMailBody($object, $xactions);
1893
$mail_tags = $this->getMailTags($object, $xactions);
1894
$action = $this->getMailAction($object, $xactions);
1896
$reply_handler = $this->buildReplyHandler($object);
1897
$reply_section = $reply_handler->getReplyHandlerInstructions();
1898
if ($reply_section !== null) {
1899
$body->addReplySection($reply_section);
1903
->setFrom($this->getActingAsPHID())
1904
->setSubjectPrefix($this->getMailSubjectPrefix())
1905
->setVarySubjectPrefix('['.$action.']')
1906
->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
1907
->setRelatedPHID($object->getPHID())
1908
->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
1909
->setMailTags($mail_tags)
1911
->setBody($body->render())
1912
->setHTMLBody($body->renderHTML());
1914
foreach ($body->getAttachments() as $attachment) {
1915
$template->addAttachment($attachment);
1918
$herald_xscript = $this->getHeraldTranscript();
1919
if ($herald_xscript) {
1920
$herald_header = $herald_xscript->getXHeraldRulesHeader();
1921
$herald_header = HeraldTranscript::saveXHeraldRulesHeader(
1925
$herald_header = HeraldTranscript::loadXHeraldRulesHeader(
1926
$object->getPHID());
1929
if ($herald_header) {
1930
$template->addHeader('X-Herald-Rules', $herald_header);
1933
if ($object instanceof PhabricatorProjectInterface) {
1934
$this->addMailProjectMetadata($object, $template);
1937
if ($this->getParentMessageID()) {
1938
$template->setParentMessageID($this->getParentMessageID());
1941
$mails = $reply_handler->multiplexMail(
1943
array_select_keys($handles, $email_to),
1944
array_select_keys($handles, $email_cc));
1946
foreach ($mails as $mail) {
1947
$mail->saveAndSend();
1950
$template->addTos($email_to);
1951
$template->addCCs($email_cc);
1956
private function addMailProjectMetadata(
1957
PhabricatorLiskDAO $object,
1958
PhabricatorMetaMTAMail $template) {
1960
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
1962
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
1964
if (!$project_phids) {
1968
// TODO: This viewer isn't quite right. It would be slightly better to use
1969
// the mail recipient, but that's not very easy given the way rendering
1972
$handles = id(new PhabricatorHandleQuery())
1973
->setViewer($this->requireActor())
1974
->withPHIDs($project_phids)
1977
$project_tags = array();
1978
foreach ($handles as $handle) {
1979
if (!$handle->isComplete()) {
1982
$project_tags[] = '<'.$handle->getObjectName().'>';
1985
if (!$project_tags) {
1989
$project_tags = implode(', ', $project_tags);
1990
$template->addHeader('X-Phabricator-Projects', $project_tags);
1994
protected function getMailThreadID(PhabricatorLiskDAO $object) {
1995
return $object->getPHID();
2002
protected function getStrongestAction(
2003
PhabricatorLiskDAO $object,
2005
return last(msort($xactions, 'getActionStrength'));
2012
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
2013
throw new Exception('Capability not supported.');
2020
protected function getMailSubjectPrefix() {
2021
throw new Exception('Capability not supported.');
2028
protected function getMailTags(
2029
PhabricatorLiskDAO $object,
2033
foreach ($xactions as $xaction) {
2034
$tags[] = $xaction->getMailTags();
2037
return array_mergev($tags);
2043
public function getMailTagsMap() {
2044
// TODO: We should move shared mail tags, like "comment", here.
2052
protected function getMailAction(
2053
PhabricatorLiskDAO $object,
2055
return $this->getStrongestAction($object, $xactions)->getActionName();
2062
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
2063
throw new Exception('Capability not supported.');
2070
protected function getMailTo(PhabricatorLiskDAO $object) {
2071
throw new Exception('Capability not supported.');
2078
protected function getMailCC(PhabricatorLiskDAO $object) {
2080
$has_support = false;
2082
if ($object instanceof PhabricatorSubscribableInterface) {
2083
$phids[] = $this->subscribers;
2084
$has_support = true;
2087
if ($object instanceof PhabricatorProjectInterface) {
2088
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
2090
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
2092
if ($project_phids) {
2093
$watcher_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER;
2095
$query = id(new PhabricatorEdgeQuery())
2096
->withSourcePHIDs($project_phids)
2097
->withEdgeTypes(array($watcher_type));
2100
$watcher_phids = $query->getDestinationPHIDs();
2101
if ($watcher_phids) {
2102
// We need to do a visibility check for all the watchers, as
2103
// watching a project is not a guarantee that you can see objects
2104
// associated with it.
2105
$users = id(new PhabricatorPeopleQuery())
2106
->setViewer($this->requireActor())
2107
->withPHIDs($watcher_phids)
2110
$watchers = array();
2111
foreach ($users as $user) {
2112
$can_see = PhabricatorPolicyFilter::hasCapability(
2115
PhabricatorPolicyCapability::CAN_VIEW);
2117
$watchers[] = $user->getPHID();
2120
$phids[] = $watchers;
2124
$has_support = true;
2127
if (!$has_support) {
2128
throw new Exception('Capability not supported.');
2131
return array_mergev($phids);
2138
protected function buildMailBody(
2139
PhabricatorLiskDAO $object,
2143
$comments = array();
2145
foreach ($xactions as $xaction) {
2146
if ($xaction->shouldHideForMail($xactions)) {
2150
$header = $xaction->getTitleForMail();
2151
if ($header !== null) {
2152
$headers[] = $header;
2155
$comment = $xaction->getBodyForMail();
2156
if ($comment !== null) {
2157
$comments[] = $comment;
2161
$body = new PhabricatorMetaMTAMailBody();
2162
$body->addRawSection(implode("\n", $headers));
2164
foreach ($comments as $comment) {
2165
$body->addRawSection($comment);
2168
if ($object instanceof PhabricatorCustomFieldInterface) {
2169
$field_list = PhabricatorCustomField::getObjectFields(
2171
PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
2172
$field_list->setViewer($this->getActor());
2173
$field_list->readFieldsFromStorage($object);
2175
foreach ($field_list->getFields() as $field) {
2176
$field->updateTransactionMailBody(
2187
/* -( Publishing Feed Stories )-------------------------------------------- */
2193
protected function shouldPublishFeedStory(
2194
PhabricatorLiskDAO $object,
2203
protected function getFeedStoryType() {
2204
return 'PhabricatorApplicationTransactionFeedStory';
2211
protected function getFeedRelatedPHIDs(
2212
PhabricatorLiskDAO $object,
2217
$this->getActingAsPHID(),
2220
if ($object instanceof PhabricatorProjectInterface) {
2221
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
2223
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
2224
foreach ($project_phids as $project_phid) {
2225
$phids[] = $project_phid;
2236
protected function getFeedNotifyPHIDs(
2237
PhabricatorLiskDAO $object,
2240
return array_unique(array_merge(
2241
$this->getMailTo($object),
2242
$this->getMailCC($object)));
2249
protected function getFeedStoryData(
2250
PhabricatorLiskDAO $object,
2253
$xactions = msort($xactions, 'getActionStrength');
2254
$xactions = array_reverse($xactions);
2257
'objectPHID' => $object->getPHID(),
2258
'transactionPHIDs' => mpull($xactions, 'getPHID'),
2266
protected function publishFeedStory(
2267
PhabricatorLiskDAO $object,
2269
array $mailed_phids) {
2271
$xactions = mfilter($xactions, 'shouldHideForFeed', true);
2277
$related_phids = $this->getFeedRelatedPHIDs($object, $xactions);
2278
$subscribed_phids = $this->getFeedNotifyPHIDs($object, $xactions);
2280
$story_type = $this->getFeedStoryType();
2281
$story_data = $this->getFeedStoryData($object, $xactions);
2283
id(new PhabricatorFeedStoryPublisher())
2284
->setStoryType($story_type)
2285
->setStoryData($story_data)
2286
->setStoryTime(time())
2287
->setStoryAuthorPHID($this->getActingAsPHID())
2288
->setRelatedPHIDs($related_phids)
2289
->setPrimaryObjectPHID($object->getPHID())
2290
->setSubscribedPHIDs($subscribed_phids)
2291
->setMailRecipientPHIDs($mailed_phids)
2292
->setMailTags($this->getMailTags($object, $xactions))
2297
/* -( Search Index )------------------------------------------------------- */
2303
protected function supportsSearch() {
2308
/* -( Herald Integration )-------------------------------------------------- */
2311
protected function shouldApplyHeraldRules(
2312
PhabricatorLiskDAO $object,
2317
protected function buildHeraldAdapter(
2318
PhabricatorLiskDAO $object,
2320
throw new Exception('No herald adapter specified.');
2323
private function setHeraldAdapter(HeraldAdapter $adapter) {
2324
$this->heraldAdapter = $adapter;
2328
protected function getHeraldAdapter() {
2329
return $this->heraldAdapter;
2332
private function setHeraldTranscript(HeraldTranscript $transcript) {
2333
$this->heraldTranscript = $transcript;
2337
protected function getHeraldTranscript() {
2338
return $this->heraldTranscript;
2341
private function applyHeraldRules(
2342
PhabricatorLiskDAO $object,
2345
$adapter = $this->buildHeraldAdapter($object, $xactions);
2346
$adapter->setContentSource($this->getContentSource());
2347
$adapter->setIsNewObject($this->getIsNewObject());
2348
$xscript = HeraldEngine::loadAndApplyRules($adapter);
2350
$this->setHeraldAdapter($adapter);
2351
$this->setHeraldTranscript($xscript);
2354
$this->didApplyHeraldRules($object, $adapter, $xscript),
2355
$adapter->getQueuedTransactions());
2358
protected function didApplyHeraldRules(
2359
PhabricatorLiskDAO $object,
2360
HeraldAdapter $adapter,
2361
HeraldTranscript $transcript) {
2366
/* -( Custom Fields )------------------------------------------------------ */
2372
private function getCustomFieldForTransaction(
2373
PhabricatorLiskDAO $object,
2374
PhabricatorApplicationTransaction $xaction) {
2376
$field_key = $xaction->getMetadataValue('customfield:key');
2378
throw new Exception(
2379
"Custom field transaction has no 'customfield:key'!");
2382
$field = PhabricatorCustomField::getObjectField(
2384
PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
2388
throw new Exception(
2389
"Custom field transaction has invalid 'customfield:key'; field ".
2390
"'{$field_key}' is disabled or does not exist.");
2393
if (!$field->shouldAppearInApplicationTransactions()) {
2394
throw new Exception(
2395
"Custom field transaction '{$field_key}' does not implement ".
2396
"integration for ApplicationTransactions.");
2399
$field->setViewer($this->getActor());
2405
/* -( Files )-------------------------------------------------------------- */
2409
* Extract the PHIDs of any files which these transactions attach.
2413
private function extractFilePHIDs(
2414
PhabricatorLiskDAO $object,
2418
foreach ($xactions as $xaction) {
2419
$blocks[] = $this->getRemarkupBlocksFromTransaction($xaction);
2421
$blocks = array_mergev($blocks);
2425
$phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
2430
foreach ($xactions as $xaction) {
2431
$phids[] = $this->extractFilePHIDsFromCustomTransaction(
2436
$phids = array_unique(array_filter(array_mergev($phids)));
2441
// Only let a user attach files they can actually see, since this would
2442
// otherwise let you access any file by attaching it to an object you have
2443
// view permission on.
2445
$files = id(new PhabricatorFileQuery())
2446
->setViewer($this->getActor())
2450
return mpull($files, 'getPHID');
2456
protected function extractFilePHIDsFromCustomTransaction(
2457
PhabricatorLiskDAO $object,
2458
PhabricatorApplicationTransaction $xaction) {
2466
private function attachFiles(
2467
PhabricatorLiskDAO $object,
2468
array $file_phids) {
2474
$editor = new PhabricatorEdgeEditor();
2476
$src = $object->getPHID();
2477
$type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_FILE;
2478
foreach ($file_phids as $dst) {
2479
$editor->addEdge($src, $type, $dst);
2485
private function applyInverseEdgeTransactions(
2486
PhabricatorLiskDAO $object,
2487
PhabricatorApplicationTransaction $xaction,
2490
$old = $xaction->getOldValue();
2491
$new = $xaction->getNewValue();
2493
$add = array_keys(array_diff_key($new, $old));
2494
$rem = array_keys(array_diff_key($old, $new));
2496
$add = array_fuse($add);
2497
$rem = array_fuse($rem);
2500
$nodes = id(new PhabricatorObjectQuery())
2501
->setViewer($this->requireActor())
2505
foreach ($nodes as $node) {
2506
if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
2510
$editor = $node->getApplicationTransactionEditor();
2511
$template = $node->getApplicationTransactionTemplate();
2512
$target = $node->getApplicationTransactionObject();
2514
if (isset($add[$node->getPHID()])) {
2515
$edge_edit_type = '+';
2517
$edge_edit_type = '-';
2521
->setTransactionType($xaction->getTransactionType())
2522
->setMetadataValue('edge:type', $inverse_type)
2525
$edge_edit_type => array($object->getPHID() => $object->getPHID()),
2529
->setContinueOnNoEffect(true)
2530
->setContinueOnMissingFields(true)
2531
->setParentMessageID($this->getParentMessageID())
2532
->setIsInverseEdgeEditor(true)
2533
->setActor($this->requireActor())
2534
->setActingAsPHID($this->getActingAsPHID())
2535
->setContentSource($this->getContentSource());
2537
$editor->applyTransactions($target, array($template));