@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.

Projects - add "Additional Hashtags" to projects

Summary:
Fixes T4021. Chooses to keep a "primary" slug based off the name - including all that lovely logic - and allow the user to specify "additional" slugs. Expose these as "hashtags" to the user.

Sets us up for a fun diff where we can delete all the Project => Phriction automagicalness. In terms of this diff, see the TODOs i added.

Test Plan:
added a primary slug as an additional slug - got an error. added a slug in use on another project - got an error. added multiple good slugs and they worked. removed slugs and it worked. made some remark using multiple new slugs and they all linked to the correct project

ran epriestley's case

- Create project "A".
- Give it additional slug "B".
- Try to create project "B".

and i got a nice error about hashtag collision

Reviewers: epriestley

Reviewed By: epriestley

Subscribers: epriestley, Korvin

Maniphest Tasks: T4021

Differential Revision: https://secure.phabricator.com/D9250

+324 -30
+9
resources/sql/autopatches/20140521.projectslug.1.create.sql
··· 1 + CREATE TABLE {$NAMESPACE}_project.project_slug ( 2 + id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, 3 + projectPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, 4 + slug VARCHAR(128) NOT NULL COLLATE utf8_bin, 5 + dateCreated INT UNSIGNED NOT NULL, 6 + dateModified INT UNSIGNED NOT NULL, 7 + UNIQUE KEY `key_slug` (slug), 8 + KEY `key_projectPHID` (projectPHID) 9 + ) ENGINE=InnoDB, COLLATE utf8_general_ci;
+33
resources/sql/autopatches/20140521.projectslug.2.mig.php
··· 1 + <?php 2 + 3 + $project_table = new PhabricatorProject(); 4 + $table_name = $project_table->getTableName(); 5 + $conn_w = $project_table->establishConnection('w'); 6 + $slug_table_name = id(new PhabricatorProjectSlug())->getTableName(); 7 + $time = time(); 8 + 9 + echo "Migrating project phriction slugs...\n"; 10 + foreach (new LiskMigrationIterator($project_table) as $project) { 11 + $id = $project->getID(); 12 + 13 + echo "Migrating project {$id}...\n"; 14 + $phriction_slug = rtrim($project->getPhrictionSlug(), '/'); 15 + $slug = id(new PhabricatorProjectSlug()) 16 + ->loadOneWhere('slug = %s', $phriction_slug); 17 + if ($slug) { 18 + echo "Already migrated {$id}... Continuing.\n"; 19 + continue; 20 + } 21 + queryfx( 22 + $conn_w, 23 + 'INSERT INTO %T (projectPHID, slug, dateCreated, dateModified) '. 24 + 'VALUES (%s, %s, %d, %d)', 25 + $slug_table_name, 26 + $project->getPHID(), 27 + $phriction_slug, 28 + $time, 29 + $time); 30 + echo "Migrated {$id}.\n"; 31 + } 32 + 33 + echo "Done.\n";
+2
src/__phutil_library_map__.php
··· 1963 1963 'PhabricatorProjectQuery' => 'applications/project/query/PhabricatorProjectQuery.php', 1964 1964 'PhabricatorProjectSearchEngine' => 'applications/project/query/PhabricatorProjectSearchEngine.php', 1965 1965 'PhabricatorProjectSearchIndexer' => 'applications/project/search/PhabricatorProjectSearchIndexer.php', 1966 + 'PhabricatorProjectSlug' => 'applications/project/storage/PhabricatorProjectSlug.php', 1966 1967 'PhabricatorProjectStandardCustomField' => 'applications/project/customfield/PhabricatorProjectStandardCustomField.php', 1967 1968 'PhabricatorProjectStatus' => 'applications/project/constants/PhabricatorProjectStatus.php', 1968 1969 'PhabricatorProjectTestDataGenerator' => 'applications/project/lipsum/PhabricatorProjectTestDataGenerator.php', ··· 4787 4788 'PhabricatorProjectQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 4788 4789 'PhabricatorProjectSearchEngine' => 'PhabricatorApplicationSearchEngine', 4789 4790 'PhabricatorProjectSearchIndexer' => 'PhabricatorSearchDocumentIndexer', 4791 + 'PhabricatorProjectSlug' => 'PhabricatorProjectDAO', 4790 4792 'PhabricatorProjectStandardCustomField' => 4791 4793 array( 4792 4794 0 => 'PhabricatorProjectCustomField',
+19 -20
src/applications/project/controller/PhabricatorProjectCreateController.php
··· 15 15 $project = PhabricatorProject::initializeNewProject($user); 16 16 17 17 $e_name = true; 18 - $errors = array(); 18 + $type_name = PhabricatorProjectTransaction::TYPE_NAME; 19 + $v_name = $project->getName(); 20 + $validation_exception = null; 19 21 if ($request->isFormPost()) { 20 22 $xactions = array(); 23 + $v_name = $request->getStr('name'); 21 24 22 25 $xactions[] = id(new PhabricatorProjectTransaction()) 23 - ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) 24 - ->setNewValue($request->getStr('name')); 26 + ->setTransactionType($type_name) 27 + ->setNewValue($v_name); 25 28 26 29 $xactions[] = id(new PhabricatorProjectTransaction()) 27 30 ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ··· 34 37 $editor = id(new PhabricatorProjectTransactionEditor()) 35 38 ->setActor($user) 36 39 ->setContinueOnNoEffect(true) 37 - ->setContentSourceFromRequest($request) 38 - ->applyTransactions($project, $xactions); 39 - 40 - // TODO: Deal with name collision exceptions more gracefully. 41 - 42 - if (!$errors) { 43 - $project->save(); 44 - 40 + ->setContentSourceFromRequest($request); 41 + try { 42 + $editor->applyTransactions($project, $xactions); 45 43 if ($request->isAjax()) { 46 44 return id(new AphrontAjaxResponse()) 47 45 ->setContent(array( ··· 52 50 return id(new AphrontRedirectResponse()) 53 51 ->setURI('/project/view/'.$project->getID().'/'); 54 52 } 53 + } catch (PhabricatorApplicationTransactionValidationException $ex) { 54 + $validation_exception = $ex; 55 + $e_name = $ex->getShortMessage($type_name); 55 56 } 56 - } 57 - 58 - $error_view = null; 59 - if ($errors) { 60 - $error_view = new AphrontErrorView(); 61 - $error_view->setErrors($errors); 62 57 } 63 58 64 59 if ($request->isAjax()) { ··· 73 68 id(new AphrontFormTextControl()) 74 69 ->setLabel(pht('Name')) 75 70 ->setName('name') 76 - ->setValue($project->getName()) 71 + ->setValue($v_name) 77 72 ->setError($e_name)); 78 73 79 74 if ($request->isAjax()) { 75 + $errors = array(); 76 + if ($validation_exception) { 77 + $errors = mpull($ex->getErrors(), 'getMessage'); 78 + } 80 79 $dialog = id(new AphrontDialogView()) 81 80 ->setUser($user) 82 81 ->setWidth(AphrontDialogView::WIDTH_FORM) 83 82 ->setTitle(pht('Create a New Project')) 84 - ->appendChild($error_view) 83 + ->setErrors($errors) 85 84 ->appendChild($form) 86 85 ->addSubmitButton(pht('Create Project')) 87 86 ->addCancelButton('/project/'); ··· 101 100 102 101 $form_box = id(new PHUIObjectBoxView()) 103 102 ->setHeaderText(pht('Create New Project')) 104 - ->setFormErrors($errors) 103 + ->setValidationException($validation_exception) 105 104 ->setForm($form); 106 105 107 106 return $this->buildApplicationPage(
+30 -2
src/applications/project/controller/PhabricatorProjectEditDetailsController.php
··· 16 16 $project = id(new PhabricatorProjectQuery()) 17 17 ->setViewer($viewer) 18 18 ->withIDs(array($this->id)) 19 + ->needSlugs(true) 19 20 ->requireCapabilities( 20 21 array( 21 22 PhabricatorPolicyCapability::CAN_VIEW, ··· 37 38 $edit_uri = $this->getApplicationURI('edit/'.$project->getID().'/'); 38 39 39 40 $e_name = true; 41 + $e_slugs = false; 40 42 $e_edit = null; 41 43 42 44 $v_name = $project->getName(); 45 + $project_slugs = $project->getSlugs(); 46 + $project_slugs = mpull($project_slugs, 'getSlug', 'getSlug'); 47 + $v_primary_slug = $project->getPrimarySlug(); 48 + unset($project_slugs[$v_primary_slug]); 49 + $v_slugs = $project_slugs; 43 50 44 51 $validation_exception = null; 45 52 46 53 if ($request->isFormPost()) { 47 54 $e_name = null; 55 + $e_slugs = null; 48 56 49 57 $v_name = $request->getStr('name'); 58 + $v_slugs = $request->getStrList('slugs'); 50 59 $v_view = $request->getStr('can_view'); 51 60 $v_edit = $request->getStr('can_edit'); 52 61 $v_join = $request->getStr('can_join'); ··· 56 65 $request); 57 66 58 67 $type_name = PhabricatorProjectTransaction::TYPE_NAME; 68 + $type_slugs = PhabricatorProjectTransaction::TYPE_SLUGS; 59 69 $type_edit = PhabricatorTransactions::TYPE_EDIT_POLICY; 60 70 61 71 $xactions[] = id(new PhabricatorProjectTransaction()) 62 72 ->setTransactionType($type_name) 63 - ->setNewValue($request->getStr('name')); 73 + ->setNewValue($v_name); 74 + 75 + $xactions[] = id(new PhabricatorProjectTransaction()) 76 + ->setTransactionType($type_slugs) 77 + ->setNewValue($v_slugs); 64 78 65 79 $xactions[] = id(new PhabricatorProjectTransaction()) 66 80 ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ··· 87 101 $validation_exception = $ex; 88 102 89 103 $e_name = $ex->getShortMessage($type_name); 104 + $e_slugs = $ex->getShortMessage($type_slugs); 90 105 $e_edit = $ex->getShortMessage($type_edit); 91 106 92 107 $project->setViewPolicy($v_view); ··· 102 117 ->setViewer($viewer) 103 118 ->setObject($project) 104 119 ->execute(); 120 + $v_slugs = implode(', ', $v_slugs); 105 121 106 122 $form = new AphrontFormView(); 107 123 $form ··· 112 128 ->setName('name') 113 129 ->setValue($v_name) 114 130 ->setError($e_name)); 115 - 116 131 $field_list->appendFieldsToForm($form); 117 132 118 133 $form 134 + ->appendChild( 135 + id(new AphrontFormStaticControl()) 136 + ->setLabel(pht('Primary Hashtag')) 137 + ->setCaption(pht('The primary hashtag is derived from the name.')) 138 + ->setValue($v_primary_slug)) 139 + ->appendChild( 140 + id(new AphrontFormTextControl()) 141 + ->setLabel(pht('Additional Hashtags')) 142 + ->setCaption(pht( 143 + 'Specify a comma-separated list of additional hashtags.')) 144 + ->setName('slugs') 145 + ->setValue($v_slugs) 146 + ->setError($e_slugs)) 119 147 ->appendChild( 120 148 id(new AphrontFormPolicyControl()) 121 149 ->setUser($viewer)
+104 -1
src/applications/project/editor/PhabricatorProjectTransactionEditor.php
··· 12 12 $types[] = PhabricatorTransactions::TYPE_JOIN_POLICY; 13 13 14 14 $types[] = PhabricatorProjectTransaction::TYPE_NAME; 15 + $types[] = PhabricatorProjectTransaction::TYPE_SLUGS; 15 16 $types[] = PhabricatorProjectTransaction::TYPE_STATUS; 16 17 $types[] = PhabricatorProjectTransaction::TYPE_IMAGE; 17 18 ··· 25 26 switch ($xaction->getTransactionType()) { 26 27 case PhabricatorProjectTransaction::TYPE_NAME: 27 28 return $object->getName(); 29 + case PhabricatorProjectTransaction::TYPE_SLUGS: 30 + $slugs = $object->getSlugs(); 31 + $slugs = mpull($slugs, 'getSlug', 'getSlug'); 32 + unset($slugs[$object->getPrimarySlug()]); 33 + return $slugs; 28 34 case PhabricatorProjectTransaction::TYPE_STATUS: 29 35 return $object->getStatus(); 30 36 case PhabricatorProjectTransaction::TYPE_IMAGE: ··· 40 46 41 47 switch ($xaction->getTransactionType()) { 42 48 case PhabricatorProjectTransaction::TYPE_NAME: 49 + case PhabricatorProjectTransaction::TYPE_SLUGS: 43 50 case PhabricatorProjectTransaction::TYPE_STATUS: 44 51 case PhabricatorProjectTransaction::TYPE_IMAGE: 45 52 return $xaction->getNewValue(); ··· 57 64 $object->setName($xaction->getNewValue()); 58 65 $object->setPhrictionSlug($xaction->getNewValue()); 59 66 return; 67 + case PhabricatorProjectTransaction::TYPE_SLUGS: 68 + return; 60 69 case PhabricatorProjectTransaction::TYPE_STATUS: 61 70 $object->setStatus($xaction->getNewValue()); 62 71 return; ··· 85 94 86 95 switch ($xaction->getTransactionType()) { 87 96 case PhabricatorProjectTransaction::TYPE_NAME: 97 + $new_slug = id(new PhabricatorProjectSlug()) 98 + ->setSlug($object->getPrimarySlug()) 99 + ->setProjectPHID($object->getPHID()) 100 + ->save(); 101 + 102 + if ($xaction->getOldValue() !== null) { 103 + $clone_object = clone $object; 104 + $clone_object->setPhrictionSlug($xaction->getOldValue()); 105 + $old_slug = $clone_object->getPrimarySlug(); 106 + $old_slug = id(new PhabricatorProjectSlug()) 107 + ->loadOneWhere('slug = %s', $old_slug); 108 + if ($old_slug) { 109 + $old_slug->delete(); 110 + } 111 + } 112 + 113 + // TODO -- delete all of the below once we sever automagical project 114 + // to phriction stuff 88 115 if ($xaction->getOldValue() === null) { 89 116 // Project was just created, we don't need to move anything. 90 117 return; ··· 118 145 $from_editor->moveAway($target_document->getID()); 119 146 } 120 147 return; 148 + case PhabricatorProjectTransaction::TYPE_SLUGS: 149 + $old = $xaction->getOldValue(); 150 + $new = $xaction->getNewValue(); 151 + $add = array_diff($new, $old); 152 + $rem = array_diff($old, $new); 153 + 154 + if ($add) { 155 + $add_slug_template = id(new PhabricatorProjectSlug()) 156 + ->setProjectPHID($object->getPHID()); 157 + foreach ($add as $add_slug_str) { 158 + $add_slug = id(clone $add_slug_template) 159 + ->setSlug($add_slug_str) 160 + ->save(); 161 + } 162 + } 163 + if ($rem) { 164 + $rem_slugs = id(new PhabricatorProjectSlug()) 165 + ->loadAllWhere('slug IN (%Ls)', $rem); 166 + foreach ($rem_slugs as $rem_slug) { 167 + $rem_slug->delete(); 168 + } 169 + } 170 + return; 121 171 case PhabricatorTransactions::TYPE_VIEW_POLICY: 122 172 case PhabricatorTransactions::TYPE_EDIT_POLICY: 123 173 case PhabricatorTransactions::TYPE_JOIN_POLICY: ··· 219 269 ($name_used_already->getPHID() != $object->getPHID())) { 220 270 $error = new PhabricatorApplicationTransactionValidationError( 221 271 $type, 222 - pht(''), 272 + pht('Duplicate'), 223 273 pht('Project name is already used.'), 224 274 nonempty(last($xactions), null)); 225 275 $errors[] = $error; 226 276 } 277 + 278 + $slug_builder = clone $object; 279 + $slug_builder->setPhrictionSlug($name); 280 + $slug = $slug_builder->getPrimarySlug(); 281 + $slug_used_already = id(new PhabricatorProjectSlug()) 282 + ->loadOneWhere('slug = %s', $slug); 283 + if ($slug_used_already && 284 + $slug_used_already->getProjectPHID() != $object->getPHID()) { 285 + $error = new PhabricatorApplicationTransactionValidationError( 286 + $type, 287 + pht('Duplicate'), 288 + pht('Project name can not be used due to hashtag collision.'), 289 + nonempty(last($xactions), null)); 290 + $errors[] = $error; 291 + } 292 + break; 293 + case PhabricatorProjectTransaction::TYPE_SLUGS: 294 + if (!$xactions) { 295 + break; 296 + } 297 + 298 + $slug_xaction = last($xactions); 299 + $new = $slug_xaction->getNewValue(); 300 + $slugs_used_already = id(new PhabricatorProjectSlug()) 301 + ->loadAllWhere('slug IN (%Ls)', $new); 302 + $slugs_used_already = mgroup($slugs_used_already, 'getProjectPHID'); 303 + foreach ($slugs_used_already as $project_phid => $used_slugs) { 304 + $used_slug_strs = mpull($used_slugs, 'getSlug'); 305 + if ($project_phid == $object->getPHID()) { 306 + if (in_array($object->getPrimarySlug(), $used_slug_strs)) { 307 + $error = new PhabricatorApplicationTransactionValidationError( 308 + $type, 309 + pht('Invalid'), 310 + pht( 311 + 'Project hashtag %s is already the primary hashtag.', 312 + $object->getPrimarySlug()), 313 + $slug_xaction); 314 + $errors[] = $error; 315 + } 316 + continue; 317 + } 318 + 319 + $error = new PhabricatorApplicationTransactionValidationError( 320 + $type, 321 + pht('Invalid'), 322 + pht( 323 + '%d project hashtag(s) are already used: %s', 324 + count($used_slug_strs), 325 + implode(', ', $used_slug_strs)), 326 + $slug_xaction); 327 + $errors[] = $error; 328 + } 329 + 227 330 break; 228 331 } 229 332
+8 -5
src/applications/project/phid/PhabricatorProjectPHIDTypeProject.php
··· 79 79 80 80 $projects = id(new PhabricatorProjectQuery()) 81 81 ->setViewer($query->getViewer()) 82 - ->withPhrictionSlugs(array_keys($map)) 82 + ->withSlugs(array_keys($map)) 83 + ->needSlugs(true) 83 84 ->execute(); 84 85 85 86 $result = array(); 86 87 foreach ($projects as $project) { 87 - $slugs = array($project->getPhrictionSlug()); 88 - foreach ($slugs as $slug) { 89 - foreach ($map[$slug] as $original) { 88 + $slugs = $project->getSlugs(); 89 + $slug_strs = mpull($slugs, 'getSlug'); 90 + foreach ($slug_strs as $slug) { 91 + $slug_map = idx($map, $slug, array()); 92 + foreach ($slug_map as $original) { 90 93 $result[$original] = $project; 91 94 } 92 95 } ··· 102 105 // should not. normalize() strips out most punctuation and leads to 103 106 // excessively aggressive matches. 104 107 105 - return phutil_utf8_strtolower($slug).'/'; 108 + return phutil_utf8_strtolower($slug); 106 109 } 107 110 108 111
+40 -2
src/applications/project/query/PhabricatorProjectQuery.php
··· 7 7 private $phids; 8 8 private $memberPHIDs; 9 9 private $slugs; 10 + private $phrictionSlugs; 10 11 private $names; 11 12 12 13 private $status = 'status-any'; ··· 16 17 const STATUS_ACTIVE = 'status-active'; 17 18 const STATUS_ARCHIVED = 'status-archived'; 18 19 20 + private $needSlugs; 19 21 private $needMembers; 20 22 private $needWatchers; 21 23 private $needImages; ··· 40 42 return $this; 41 43 } 42 44 43 - public function withPhrictionSlugs(array $slugs) { 45 + public function withSlugs(array $slugs) { 44 46 $this->slugs = $slugs; 45 47 return $this; 46 48 } 47 49 50 + public function withPhrictionSlugs(array $slugs) { 51 + $this->phrictionSlugs = $slugs; 52 + return $this; 53 + } 54 + 48 55 public function withNames(array $names) { 49 56 $this->names = $names; 50 57 return $this; ··· 62 69 63 70 public function needImages($need_images) { 64 71 $this->needImages = $need_images; 72 + return $this; 73 + } 74 + 75 + public function needSlugs($need_slugs) { 76 + $this->needSlugs = $need_slugs; 65 77 return $this; 66 78 } 67 79 ··· 184 196 } 185 197 } 186 198 199 + if ($this->needSlugs) { 200 + $slugs = id(new PhabricatorProjectSlug()) 201 + ->loadAllWhere( 202 + 'projectPHID IN (%Ls)', 203 + mpull($projects, 'getPHID')); 204 + $slugs = mgroup($slugs, 'getProjectPHID'); 205 + foreach ($projects as $project) { 206 + $project_slugs = idx($slugs, $project->getPHID(), array()); 207 + $project->attachSlugs($project_slugs); 208 + } 209 + } 210 + 187 211 return $projects; 188 212 } 189 213 ··· 238 262 if ($this->slugs) { 239 263 $where[] = qsprintf( 240 264 $conn_r, 265 + 'slug.slug IN (%Ls)', 266 + $this->slugs); 267 + } 268 + 269 + if ($this->phrictionSlugs) { 270 + $where[] = qsprintf( 271 + $conn_r, 241 272 'phrictionSlug IN (%Ls)', 242 - $this->slugs); 273 + $this->phrictionSlugs); 243 274 } 244 275 245 276 if ($this->names) { ··· 280 311 'JOIN %T e ON e.src = p.phid AND e.type = %d', 281 312 PhabricatorEdgeConfig::TABLE_NAME_EDGE, 282 313 PhabricatorEdgeConfig::TYPE_PROJ_MEMBER); 314 + } 315 + 316 + if ($this->slugs) { 317 + $joins[] = qsprintf( 318 + $conn_r, 319 + 'JOIN %T slug on slug.projectPHID = p.phid', 320 + id(new PhabricatorProjectSlug())->getTableName()); 283 321 } 284 322 285 323 $joins[] = $this->buildApplicationSearchJoinClause($conn_r);
+18
src/applications/project/storage/PhabricatorProject.php
··· 24 24 private $sparseMembers = self::ATTACHABLE; 25 25 private $customFields = self::ATTACHABLE; 26 26 private $profileImageFile = self::ATTACHABLE; 27 + private $slugs = self::ATTACHABLE; 27 28 28 29 public static function initializeNewProject(PhabricatorUser $actor) { 29 30 return id(new PhabricatorProject()) ··· 143 144 return 'projects/'.$slug; 144 145 } 145 146 147 + // TODO - once we sever project => phriction automagicalness, 148 + // migrate getPhrictionSlug to have no trailing slash and be called 149 + // getPrimarySlug 150 + public function getPrimarySlug() { 151 + $slug = $this->getPhrictionSlug(); 152 + return rtrim($slug, '/'); 153 + } 154 + 146 155 public function isArchived() { 147 156 return ($this->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED); 148 157 } ··· 183 192 184 193 public function getWatcherPHIDs() { 185 194 return $this->assertAttached($this->watcherPHIDs); 195 + } 196 + 197 + public function attachSlugs(array $slugs) { 198 + $this->slugs = $slugs; 199 + return $this; 200 + } 201 + 202 + public function getSlugs() { 203 + return $this->assertAttached($this->slugs); 186 204 } 187 205 188 206
+8
src/applications/project/storage/PhabricatorProjectSlug.php
··· 1 + <?php 2 + 3 + final class PhabricatorProjectSlug extends PhabricatorProjectDAO { 4 + 5 + protected $slug; 6 + protected $projectPHID; 7 + 8 + }
+31
src/applications/project/storage/PhabricatorProjectTransaction.php
··· 4 4 extends PhabricatorApplicationTransaction { 5 5 6 6 const TYPE_NAME = 'project:name'; 7 + const TYPE_SLUGS = 'project:slugs'; 7 8 const TYPE_STATUS = 'project:status'; 8 9 const TYPE_IMAGE = 'project:image'; 9 10 ··· 84 85 $this->renderHandleLink($old), 85 86 $this->renderHandleLink($new)); 86 87 } 88 + 89 + case PhabricatorProjectTransaction::TYPE_SLUGS: 90 + $add = array_diff($new, $old); 91 + $rem = array_diff($old, $new); 92 + 93 + if ($add && $rem) { 94 + return pht( 95 + '%s changed project hashtag(s), added %d: %s; removed %d: %s', 96 + $author_handle, 97 + count($add), 98 + $this->renderSlugList($add), 99 + count($rem), 100 + $this->renderSlugList($rem)); 101 + } else if ($add) { 102 + return pht( 103 + '%s added %d project hashtag(s): %s', 104 + $author_handle, 105 + count($add), 106 + $this->renderSlugList($add)); 107 + } else if ($rem) { 108 + return pht( 109 + '%s removed %d project hashtag(s): %s', 110 + $author_handle, 111 + count($rem), 112 + $this->renderSlugList($rem)); 113 + } 114 + 87 115 case PhabricatorProjectTransaction::TYPE_MEMBERS: 88 116 $add = array_diff($new, $old); 89 117 $rem = array_diff($old, $new); ··· 126 154 return parent::getTitle(); 127 155 } 128 156 157 + private function renderSlugList($slugs) { 158 + return implode(', ', $slugs); 159 + } 129 160 130 161 }
+22
src/infrastructure/internationalization/translation/PhabricatorBaseEnglishTranslation.php
··· 835 835 ), 836 836 ), 837 837 838 + '%d project hashtag(s) are already used: %s' => array( 839 + 'Project hashtag %2$s is already used.', 840 + '%d project hashtags are already used: %2$s', 841 + ), 842 + 843 + '%s changed project hashtag(s), added %d: %s; removed %d: %s' => 844 + '%s changed project hashtags, added %3$s; removed %5$s', 845 + 846 + '%s added %d project hashtag(s): %s' => array( 847 + array( 848 + '%s added a hashtag: %3$s', 849 + '%s added hashtags: %3$s', 850 + ), 851 + ), 852 + 853 + '%s removed %d project hashtag(s): %s' => array( 854 + array( 855 + '%s removed a hashtag: %3$s', 856 + '%s removed hashtags: %3$s', 857 + ), 858 + ), 859 + 838 860 '%d User(s) Need Approval' => array( 839 861 '%d User Needs Approval', 840 862 '%d Users Need Approval',