├── LICENSE ├── README.md ├── composer.json ├── config └── Migrations │ ├── 20150412195500_create_tags_tags.php │ ├── 20150412195501_create_tags_tagged.php │ └── 20151013144821_unique_tags.php ├── phpstan.neon ├── psalm.xml └── src ├── Model ├── Behavior │ └── TagBehavior.php ├── Entity │ ├── Tag.php │ ├── TagAwareTrait.php │ └── Tagged.php └── Table │ ├── TaggedTable.php │ └── TagsTable.php └── Plugin.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-Present Use Muffin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tags 2 | 3 | [![Build Status](https://img.shields.io/travis/UseMuffin/Tags/master.svg?style=flat-square)](https://travis-ci.org/UseMuffin/Tags) 4 | [![Coverage](https://img.shields.io/codecov/c/github/UseMuffin/Tags.svg?style=flat-square)](https://codecov.io/github/UseMuffin/Tags) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/muffin/tags.svg?style=flat-square)](https://packagist.org/packages/muffin/tags) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 7 | 8 | This plugin allows you to simply tag record in your database with multiple tags. 9 | 10 | ## Install 11 | 12 | Using [Composer][composer]: 13 | 14 | ``` 15 | composer require muffin/tags 16 | ``` 17 | 18 | You then need to load the plugin. You can use the shell command: 19 | 20 | ``` 21 | bin/cake plugin load Muffin/Tags 22 | ``` 23 | 24 | or by manually adding the following line to `src/Application.php`: 25 | 26 | ```php 27 | $this->addPlugin('Muffin/Obfuscate'); 28 | ``` 29 | 30 | ## Usage 31 | 32 | ## Quick Start Guide 33 | 34 | You need to add the column *tag_count* to the taggable table. 35 | 36 | Then migrate the tables for the plugin: 37 | ``` 38 | bin/cake migrations migrate -p Muffin/Tags 39 | ``` 40 | 41 | Add the behavior: 42 | 43 | ```php 44 | $this->addBehavior('Muffin/Tags.Tag'); 45 | ``` 46 | 47 | And in the view: 48 | 49 | ```php 50 | echo $this->Form->input('tags'); 51 | ``` 52 | 53 | Enjoy tagging! 54 | 55 | ## Patches & Features 56 | 57 | * Fork 58 | * Mod, fix 59 | * Test - this is important, so it's not unintentionally broken 60 | * Commit - do not mess with license, todo, version, etc. (if you do change any, bump them into commits of 61 | their own that I can ignore when I pull) 62 | * Pull request - bonus point for topic branches 63 | 64 | To ensure your PRs are considered for upstream, you MUST follow the CakePHP coding standards. 65 | 66 | ## Bugs & Feedback 67 | 68 | http://github.com/usemuffin/tags/issues 69 | 70 | ## License 71 | 72 | Copyright (c) 2015-Present, [Use Muffin] and licensed under [The MIT License][mit]. 73 | 74 | [cakephp]:http://cakephp.org 75 | [composer]:http://getcomposer.org 76 | [mit]:http://www.opensource.org/licenses/mit-license.php 77 | [muffin]:http://usemuffin.com 78 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muffin/tags", 3 | "description": "Tags plugin for CakePHP", 4 | "type": "cakephp-plugin", 5 | "keywords": [ 6 | "cakephp", 7 | "muffin", 8 | "tags" 9 | ], 10 | "homepage": "https://github.com/usemuffin/tags", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Jad Bitar", 15 | "homepage": "http://jadb.io", 16 | "role": "Author" 17 | }, 18 | { 19 | "name": "ADmad", 20 | "homepage": "https://github.com/ADmad", 21 | "role": "Author" 22 | }, 23 | { 24 | "name": "Others", 25 | "homepage": "https://github.com/usemuffin/tags/graphs/contributors" 26 | } 27 | ], 28 | "support": { 29 | "issues": "https://github.com/usemuffin/tags/issues", 30 | "source": "https://github.com/usemuffin/tags" 31 | }, 32 | "require": { 33 | "cakephp/orm": "^4.0", 34 | "cakephp/utility": "^4.0" 35 | }, 36 | "require-dev": { 37 | "cakephp/cakephp": "^4.0", 38 | "cakephp/cakephp-codesniffer": "^4.0", 39 | "phpunit/phpunit": "~8.5.0" 40 | }, 41 | "suggest": { 42 | "muffin/slug": "For adding slugs to tags" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "Muffin\\Tags\\": "src" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Muffin\\Tags\\Test\\": "tests", 52 | "Muffin\\Tags\\Test\\App\\": "tests/test_app/src" 53 | } 54 | }, 55 | "config": { 56 | "sort-packages": true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/Migrations/20150412195500_create_tags_tags.php: -------------------------------------------------------------------------------- 1 | table('tags_tags'); 10 | 11 | $table->addColumn('namespace', 'string', [ 12 | 'default' => null, 13 | 'limit' => 255, 14 | 'null' => true, 15 | ]); 16 | $table->addColumn('slug', 'string', [ 17 | 'default' => null, 18 | 'limit' => 255, 19 | 'null' => false, 20 | ]); 21 | $table->addColumn('label', 'string', [ 22 | 'default' => null, 23 | 'limit' => 255, 24 | 'null' => false, 25 | ]); 26 | $table->addColumn('counter', 'integer', [ 27 | 'default' => 0, 28 | 'length' => 11, 29 | 'null' => false, 30 | 'signed' => false, 31 | ]); 32 | $table->addColumn('created', 'datetime', [ 33 | 'default' => null, 34 | 'null' => true, 35 | ]); 36 | $table->addColumn('modified', 'datetime', [ 37 | 'default' => null, 38 | 'null' => true, 39 | ]); 40 | 41 | $table->create(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /config/Migrations/20150412195501_create_tags_tagged.php: -------------------------------------------------------------------------------- 1 | table('tags_tagged'); 10 | 11 | $table->addColumn('tag_id', 'integer', [ 12 | 'default' => null, 13 | 'length' => 11, 14 | 'null' => true, 15 | ]); 16 | $table->addColumn('fk_id', 'integer', [ 17 | 'default' => null, 18 | 'length' => 11, 19 | 'null' => true, 20 | ]); 21 | $table->addColumn('fk_table', 'string', [ 22 | 'default' => null, 23 | 'limit' => 255, 24 | 'null' => false, 25 | ]); 26 | $table->addColumn('created', 'datetime', [ 27 | 'default' => null, 28 | 'null' => true, 29 | ]); 30 | $table->addColumn('modified', 'datetime', [ 31 | 'default' => null, 32 | 'null' => true, 33 | ]); 34 | 35 | $table->create(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/Migrations/20151013144821_unique_tags.php: -------------------------------------------------------------------------------- 1 | table('tags_tags'); 31 | 32 | $table->addColumn('tag_key', 'string', [ 33 | 'default' => null, 34 | 'limit' => 255, 35 | 'null' => false, 36 | ]); 37 | 38 | $table->addIndex(['tag_key', 'label', 'namespace'], ['unique' => true]); 39 | $table->update(); 40 | 41 | $table = $this->table('tags_tagged'); 42 | 43 | $table->addIndex(['tag_id', 'fk_id', 'fk_table'], ['unique' => true]); 44 | $table->update(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | checkGenericClassInNonGenericObjectType: false 4 | checkMissingIterableValueType: false 5 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Model/Behavior/TagBehavior.php: -------------------------------------------------------------------------------- 1 | ',', 26 | 'separator' => ':', 27 | 'namespace' => null, 28 | 'tagsAlias' => 'Tags', 29 | 'tagsAssoc' => [ 30 | 'className' => 'Muffin/Tags.Tags', 31 | 'joinTable' => 'tags_tagged', 32 | 'foreignKey' => 'fk_id', 33 | 'targetForeignKey' => 'tag_id', 34 | 'propertyName' => 'tags', 35 | ], 36 | 'tagsCounter' => ['counter'], 37 | 'taggedAlias' => 'Tagged', 38 | 'taggedAssoc' => [ 39 | 'className' => 'Muffin/Tags.Tagged', 40 | ], 41 | 'taggedCounter' => [ 42 | 'tag_count' => ['conditions' => []], 43 | ], 44 | 'implementedEvents' => [ 45 | 'Model.beforeMarshal' => 'beforeMarshal', 46 | ], 47 | 'implementedMethods' => [ 48 | 'normalizeTags' => 'normalizeTags', 49 | ], 50 | 'fkTableField' => 'fk_table', 51 | ]; 52 | 53 | /** 54 | * Initialize configuration. 55 | * 56 | * @param array $config Configuration array. 57 | * @return void 58 | */ 59 | public function initialize(array $config): void 60 | { 61 | $this->bindAssociations(); 62 | $this->attachCounters(); 63 | } 64 | 65 | /** 66 | * Return lists of event's this behavior is interested in. 67 | * 68 | * @return array Events list. 69 | */ 70 | public function implementedEvents(): array 71 | { 72 | return $this->getConfig('implementedEvents'); 73 | } 74 | 75 | /** 76 | * Before marshal callaback 77 | * 78 | * @param \Cake\Event\EventInterface $event The Model.beforeMarshal event. 79 | * @param \ArrayObject $data Data. 80 | * @param \ArrayObject $options Options. 81 | * @return void 82 | */ 83 | public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options) 84 | { 85 | $field = $this->getConfig('tagsAssoc.propertyName'); 86 | if (!empty($data[$field]) && (!is_array($data[$field]) || !array_key_exists('_ids', $data[$field]))) { 87 | $data[$field] = $this->normalizeTags($data[$field]); 88 | } 89 | if (isset($data[$field]) && empty($data[$field])) { 90 | unset($data[$field]); 91 | } 92 | } 93 | 94 | /** 95 | * Binds all required associations if an association of the same name has 96 | * not already been configured. 97 | * 98 | * @return void 99 | */ 100 | public function bindAssociations(): void 101 | { 102 | $config = $this->getConfig(); 103 | $tagsAlias = $config['tagsAlias']; 104 | $tagsAssoc = $config['tagsAssoc']; 105 | $taggedAlias = $config['taggedAlias']; 106 | $taggedAssoc = $config['taggedAssoc']; 107 | 108 | $table = $this->_table; 109 | $tableAlias = $this->_table->getAlias(); 110 | 111 | $assocConditions = [$taggedAlias . '.' . $this->getConfig('fkTableField') => $table->getTable()]; 112 | 113 | if (!$table->hasAssociation($taggedAlias)) { 114 | $table->hasMany($taggedAlias, $taggedAssoc + [ 115 | 'foreignKey' => $tagsAssoc['foreignKey'], 116 | 'conditions' => $assocConditions, 117 | ]); 118 | } 119 | 120 | if (!$table->hasAssociation($tagsAlias)) { 121 | $table->belongsToMany($tagsAlias, $tagsAssoc + [ 122 | 'through' => $table->{$taggedAlias}->getTarget(), 123 | 'conditions' => $assocConditions, 124 | ]); 125 | } 126 | 127 | if (!$table->{$tagsAlias}->hasAssociation($tableAlias)) { 128 | $table->{$tagsAlias} 129 | ->belongsToMany($tableAlias, [ 130 | 'className' => $table->getTable(), 131 | ] + $tagsAssoc); 132 | } 133 | 134 | if (!$table->{$taggedAlias}->hasAssociation($tableAlias)) { 135 | $table->{$taggedAlias} 136 | ->belongsTo($tableAlias, [ 137 | 'className' => $table->getTable(), 138 | 'foreignKey' => $tagsAssoc['foreignKey'], 139 | 'conditions' => $assocConditions, 140 | 'joinType' => 'INNER', 141 | ]); 142 | } 143 | 144 | if (!$table->{$taggedAlias}->hasAssociation($tableAlias . $tagsAlias)) { 145 | $table->{$taggedAlias} 146 | ->belongsTo($tableAlias . $tagsAlias, [ 147 | 'className' => $tagsAssoc['className'], 148 | 'foreignKey' => $tagsAssoc['targetForeignKey'], 149 | 'conditions' => $assocConditions, 150 | 'joinType' => 'INNER', 151 | ]); 152 | } 153 | } 154 | 155 | /** 156 | * Attaches the `CounterCache` behavior to the `Tagged` table to keep counts 157 | * on both the `Tags` and the tagged entities. 158 | * 159 | * @return void 160 | * @throws \RuntimeException If configured counter cache field does not exist in table. 161 | */ 162 | public function attachCounters(): void 163 | { 164 | $config = $this->getConfig(); 165 | $tagsAlias = $config['tagsAlias']; 166 | $taggedAlias = $config['taggedAlias']; 167 | 168 | $taggedTable = $this->_table->{$taggedAlias}; 169 | 170 | if (!$taggedTable->hasBehavior('CounterCache')) { 171 | $taggedTable->addBehavior('CounterCache'); 172 | } 173 | 174 | $counterCache = $taggedTable->behaviors()->CounterCache; 175 | 176 | if (!$counterCache->getConfig($tagsAlias)) { 177 | $counterCache->setConfig($tagsAlias, $config['tagsCounter']); 178 | } 179 | 180 | if ($config['taggedCounter'] === false) { 181 | return; 182 | } 183 | 184 | foreach ($config['taggedCounter'] as $field => $o) { 185 | if (!$this->_table->hasField($field)) { 186 | throw new RuntimeException(sprintf( 187 | 'Field "%s" does not exist in table "%s"', 188 | $field, 189 | $this->_table->getTable() 190 | )); 191 | } 192 | } 193 | 194 | if (!$counterCache->getConfig($taggedAlias)) { 195 | $field = key($config['taggedCounter']); 196 | $config['taggedCounter']['tag_count']['conditions'] = [ 197 | $taggedTable->aliasField($this->getConfig('fkTableField')) => $this->_table->getTable(), 198 | ]; 199 | $counterCache->setConfig($this->_table->getAlias(), $config['taggedCounter']); 200 | } 201 | } 202 | 203 | /** 204 | * Normalizes tags. 205 | * 206 | * @param array|string $tags List of tags as an array or a delimited string (comma by default). 207 | * @return array Normalized tags valid to be marshaled. 208 | */ 209 | public function normalizeTags($tags): array 210 | { 211 | if (is_string($tags)) { 212 | $tags = explode($this->getConfig('delimiter'), $tags); 213 | } 214 | 215 | $result = []; 216 | 217 | $common = ['_joinData' => [$this->getConfig('fkTableField') => $this->_table->getTable()]]; 218 | $namespace = $this->getConfig('namespace'); 219 | if ($namespace) { 220 | $common += compact('namespace'); 221 | } 222 | 223 | $tagsTable = $this->_table->{$this->getConfig('tagsAlias')}; 224 | $pk = $tagsTable->getPrimaryKey(); 225 | $df = $tagsTable->getDisplayField(); 226 | 227 | foreach ($tags as $tag) { 228 | $tag = trim($tag); 229 | if (empty($tag)) { 230 | continue; 231 | } 232 | $tagKey = $this->_getTagKey($tag); 233 | $existingTag = $this->_tagExists($tagKey); 234 | if (!empty($existingTag)) { 235 | $result[] = $common + ['id' => $existingTag]; 236 | continue; 237 | } 238 | [$id, $label] = $this->_normalizeTag($tag); 239 | $result[] = $common + compact(empty($id) ? $df : $pk) + [ 240 | 'tag_key' => $tagKey, 241 | ]; 242 | } 243 | 244 | return $result; 245 | } 246 | 247 | /** 248 | * Generates the unique tag key. 249 | * 250 | * @param string $tag Tag label. 251 | * @return string 252 | */ 253 | protected function _getTagKey(string $tag): string 254 | { 255 | return strtolower(Text::slug($tag)); 256 | } 257 | 258 | /** 259 | * Checks if a tag already exists and returns the id if yes. 260 | * 261 | * @param string $tag Tag key. 262 | * @return null|int 263 | */ 264 | protected function _tagExists(string $tag): ?int 265 | { 266 | $tagsTable = $this->_table->{$this->getConfig('tagsAlias')}->getTarget(); 267 | $result = $tagsTable->find() 268 | ->where([ 269 | $tagsTable->aliasField('tag_key') => $tag, 270 | ]) 271 | ->select([ 272 | $tagsTable->aliasField($tagsTable->getPrimaryKey()), 273 | ]) 274 | ->first(); 275 | if (!empty($result)) { 276 | return $result->id; 277 | } 278 | 279 | return null; 280 | } 281 | 282 | /** 283 | * Normalizes a tag string by trimming unnecessary whitespace and extracting the tag identifier 284 | * from a tag in case it exists. 285 | * 286 | * @param string $tag Tag. 287 | * @return array The tag's ID and label. 288 | */ 289 | protected function _normalizeTag(string $tag): array 290 | { 291 | $namespace = ''; 292 | $label = $tag; 293 | $separator = $this->getConfig('separator'); 294 | if (strpos($tag, $separator) !== false) { 295 | [$namespace, $label] = explode($separator, $tag); 296 | } 297 | 298 | /** @psalm-suppress PossiblyNullArgument **/ 299 | return [ 300 | trim($namespace), 301 | trim($label), 302 | ]; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/Model/Entity/Tag.php: -------------------------------------------------------------------------------- 1 | false, 17 | 'label' => true, 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /src/Model/Entity/TagAwareTrait.php: -------------------------------------------------------------------------------- 1 | _updateTags($tags, $merge ? 'append' : 'replace'); 20 | } 21 | 22 | /** 23 | * Untag entity from given tags. 24 | * 25 | * @param string|array|null $tags List of tags as an array or a delimited string (comma by default). 26 | * If no value is passed all tags will be removed. 27 | * @return bool|\Cake\ORM\Entity False on failure, entity on success. 28 | */ 29 | public function untag($tags = null) 30 | { 31 | if (empty($tags)) { 32 | return $this->_updateTags([], 'replace'); 33 | } 34 | 35 | $table = TableRegistry::getTableLocator()->get($this->source()); 36 | $behavior = $table->behaviors()->Tag; 37 | $assoc = $table->getAssociation($behavior->getConfig('tagsAlias')); 38 | $property = $assoc->getProperty(); 39 | $id = $this->get($table->getPrimaryKey()); 40 | $untags = $behavior->normalizeTags($tags); 41 | 42 | $tags = $this->get($property); 43 | if (!$tags) { 44 | $contain = [$behavior->getConfig('tagsAlias')]; 45 | $tags = $table->get($id, compact('contain'))->get($property); 46 | } 47 | 48 | $tagsTable = $table->{$behavior->getConfig('tagsAlias')}; 49 | $pk = $tagsTable->getPrimaryKey(); 50 | $df = $tagsTable->getDisplayField(); 51 | 52 | foreach ($tags as $k => $tag) { 53 | $tags[$k] = [ 54 | $pk => $tag->{$pk}, 55 | $df => $tag->{$df}, 56 | ]; 57 | } 58 | 59 | foreach ($untags as $untag) { 60 | foreach ($tags as $k => $tag) { 61 | if ( 62 | (empty($untag[$pk]) || $tag[$pk] === $untag[$pk]) && 63 | (empty($untag[$df]) || $tag[$df] === $untag[$df]) 64 | ) { 65 | unset($tags[$k]); 66 | } 67 | } 68 | } 69 | 70 | return $this->_updateTags( 71 | array_map( 72 | function ($i) { 73 | return implode(':', $i); 74 | }, 75 | $tags 76 | ), 77 | 'replace' 78 | ); 79 | } 80 | 81 | /** 82 | * Tag entity with given tags. 83 | * 84 | * @param string|array $tags List of tags as an array or a delimited string (comma by default). 85 | * @param string $saveStrategy Whether to merge or replace tags. 86 | * Valid values 'append', 'replace'. 87 | * @return bool|\Cake\ORM\Entity False on failure, entity on success. 88 | */ 89 | protected function _updateTags($tags, string $saveStrategy) 90 | { 91 | $table = TableRegistry::getTableLocator()->get($this->source()); 92 | $behavior = $table->behaviors()->Tag; 93 | $assoc = $table->getAssociation($behavior->getConfig('tagsAlias')); 94 | $resetStrategy = $assoc->getSaveStrategy(); 95 | $assoc->setSaveStrategy($saveStrategy); 96 | $table->patchEntity($this, [$assoc->getProperty() => $tags]); 97 | $result = $table->save($this); 98 | $assoc->setSaveStrategy($resetStrategy); 99 | 100 | return $result; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Model/Entity/Tagged.php: -------------------------------------------------------------------------------- 1 | false, 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /src/Model/Table/TaggedTable.php: -------------------------------------------------------------------------------- 1 | setTable('tags_tagged'); 22 | $this->addBehavior('Timestamp'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Model/Table/TagsTable.php: -------------------------------------------------------------------------------- 1 | setTable('tags_tags'); 20 | $this->setDisplayField('label'); 21 | $this->addBehavior('Timestamp'); 22 | if (Plugin::isLoaded('Muffin/Slug')) { 23 | $this->addBehavior('Muffin/Slug.Slug'); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 |