├── .editorconfig ├── .gitignore ├── .php-cs-fixer.dist.php ├── README.md ├── composer.json ├── config └── hooks.php ├── index.php ├── lib └── Similar.php └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.php] 13 | indent_size = 4 14 | 15 | [*.md,*.txt] 16 | trim_trailing_whitespace = false 17 | insert_final_newline = false 18 | 19 | [composer.json] 20 | indent_size = 4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS files 2 | .DS_Store 3 | .idea 4 | *.cache 5 | 6 | # npm modules 7 | /node_modules 8 | 9 | # Composer files 10 | /vendor 11 | /tools 12 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 5 | 6 | $config = new PhpCsFixer\Config(); 7 | return $config 8 | ->setRules([ 9 | '@PSR1' => true, 10 | '@PSR2' => true, 11 | 'align_multiline_comment' => ['comment_type' => 'phpdocs_like'], 12 | 'array_indentation' => true, 13 | 'array_syntax' => ['syntax' => 'short'], 14 | 'binary_operator_spaces' => 15 | ['operators' => ['=>' => 'align', '=' => 'align']], 16 | 'cast_spaces' => ['space' => 'none'], 17 | 'combine_consecutive_issets' => true, 18 | 'combine_consecutive_unsets' => true, 19 | 'combine_nested_dirname' => true, 20 | 'concat_space' => ['spacing' => 'one'], 21 | 'declare_equal_normalize' => ['space' => 'single'], 22 | 'dir_constant' => true, 23 | 'function_typehint_space' => true, 24 | 'include' => true, 25 | 'logical_operators' => true, 26 | 'lowercase_cast' => true, 27 | 'lowercase_static_reference' => true, 28 | 'magic_constant_casing' => true, 29 | 'magic_method_casing' => true, 30 | 'method_chaining_indentation' => true, 31 | 'modernize_types_casting' => true, 32 | 'multiline_comment_opening_closing' => true, 33 | 'native_function_casing' => true, 34 | 'native_function_type_declaration_casing' => true, 35 | 'new_with_braces' => true, 36 | 'no_blank_lines_after_class_opening' => true, 37 | 'no_blank_lines_after_phpdoc' => true, 38 | 'no_empty_comment' => true, 39 | 'no_empty_phpdoc' => true, 40 | 'no_empty_statement' => true, 41 | 'no_leading_namespace_whitespace' => true, 42 | 'no_mixed_echo_print' => ['use' => 'echo'], 43 | 'no_unneeded_control_parentheses' => true, 44 | 'no_unused_imports' => true, 45 | 'no_useless_return' => true, 46 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 47 | 'phpdoc_align' => ['align' => 'left'], 48 | 'phpdoc_indent' => true, 49 | 'phpdoc_scalar' => true, 50 | 'phpdoc_trim' => true, 51 | 'short_scalar_cast' => true, 52 | 'single_line_comment_style' => true, 53 | 'single_quote' => true, 54 | 'ternary_to_null_coalescing' => true, 55 | 'whitespace_after_comma_in_array' => true 56 | ]) 57 | ->setRiskyAllowed(true) 58 | ->setFinder($finder); 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release](https://img.shields.io/github/release/texnixe/kirby3-similar.svg?maxAge=1800) ![License](https://img.shields.io/github/license/mashape/apistatus.svg) ![Kirby 3 Pluginkit](https://img.shields.io/badge/Pluginkit-YES-cca000.svg) 2 | 3 | # Kirby Similar 4 | 5 | Find related pages or files. Kirby 3 Similar is a [Kirby CMS](https://getkirby.com) plugin that lets you find items related to the current item based on the similarity between fields. For each given field, the plugin calculates the Jaccard Index and then weighs all indices based on the factor for each field. 6 | 7 | Example use case: 8 | The current page has a tags field with three values (red, green, blue). You want to find all sibling pages with a minimum Jaccard Index of 0.3 (which possible values between 0 and 1). 9 | 10 | ## Commercial Usage 11 | 12 | This plugin is free but if you use it in a commercial project please consider 13 | 14 | - [making a donation](https://www.paypal.me/texnixe/10) or 15 | - [buying a Kirby license using this affiliate link](https://a.paddle.com/v2/click/1129/38380?link=1170) 16 | 17 | ## How is it different from the Kirby 3 Related plugin 18 | 19 | - It allows you to pass multiple fields as an array with a factor for each field, depending on the importance of this field for determining the similarity. 20 | - The similarity is calculated according to the Jaccard Index, rather than by the number of matches as in the Kirby 3 Related plugin. 21 | 22 | A quick example that describes the difference: 23 | 24 | **Example 1:** 25 | 26 | Page A: blue, green 27 | Page B: blue, green 28 | 29 | Matches: 2 30 | Jaccard Index: 2/2 = 1 31 | 32 | **Example 2:** 33 | 34 | Page A: blue, green, yellow 35 | Page B: blue, green 36 | 37 | Matches: 2 38 | Jaccard Index: 2/3 = 0.66666 39 | 40 | While both pages have the same number of matches, the Jaccard Index is lower in the second example, because the number of unique tags is taken into account as well. 41 | 42 | 43 | ## Installation 44 | 45 | ### Download 46 | 47 | [Download the files](https://github.com/texnixe/kirby3-similar/archive/master.zip) and place them inside `site/plugins/kirby-similar`. 48 | 49 | ### Git Submodule 50 | You can add the plugin as a Git submodule. 51 | 52 | $ cd your/project/root 53 | $ git submodule add https://github.com/texnixe/kirby3-similar.git site/plugins/kirby-similar 54 | $ git submodule update --init --recursive 55 | $ git commit -am "Add Kirby Similar plugin" 56 | 57 | Run these commands to update the plugin: 58 | 59 | $ cd your/project/root 60 | $ git submodule foreach git checkout master 61 | $ git submodule foreach git pull 62 | $ git commit -am "Update submodules" 63 | $ git submodule update --init --recursive 64 | 65 | ## Usage 66 | 67 | ### Similar pages 68 | ``` 69 | similar($options); 72 | 73 | foreach($similarPages as $p) { 74 | echo $p->title(); 75 | } 76 | 77 | ``` 78 | 79 | ### Similar files 80 | 81 | ``` 82 | similar($options); 85 | 86 | foreach($similarImages as $image) { 87 | echo $image->filename(); 88 | } 89 | 90 | ``` 91 | 92 | ### Options 93 | 94 | You can pass an array of options: 95 | 96 | ``` 97 | similar([ 99 | 'index' => $page->siblings(false)->listed(), 100 | 'fields' => 'tags', 101 | 'threshold' => 0.2, 102 | 'delimiter' => ',', 103 | 'languageFilter' => false 104 | ]); 105 | ?> 106 | ``` 107 | #### index 108 | 109 | The collection to search in. 110 | Default: `$item->siblings(false)` (The `false` argument excludes the current page from the collection) 111 | #### fields 112 | 113 | The name of the field to search in. 114 | Default: tags 115 | 116 | **Single field** 117 | You can pass a single field as string: 118 | 119 | ```php 120 | 'fields' => 'tags' 121 | ``` 122 | 123 | **Multiple fields** 124 | 125 | You can also pass multiple fields as array: 126 | 127 | ```php 128 | 'fields' => ['tags', 'size', 'category'] 129 | ``` 130 | 131 | In this case, all fields get the same factor 1. 132 | 133 | You can also pass an associative array with a factor for each field: 134 | 135 | ```php 136 | 'fields' => ['tags' => 1, 'size' => 1.5, 'category' => 3] 137 | ``` 138 | 139 | You might want to change the factor of individual fields when filtering collections to get better result. For example, assign a higher factor dynamically if the filter parameter is set to `size`: 140 | 141 | ```php 142 | 'fields' => ['tags' => 0.5, 'size' => 2, 'category' => 1] 143 | ``` 144 | 145 | #### delimiter 146 | 147 | The delimiter that you use to separate values in a field 148 | Default: `,` 149 | 150 | #### threshold 151 | 152 | The minimum Jaccard Index, i.e. a value between 0 (no similarity) and 1 (full similarity) 153 | Default: `0.1` 154 | 155 | #### languageFilter 156 | 157 | Filter similar items by language in a multi-language installation. 158 | Default: `false` 159 | 160 | 161 | ## License 162 | 163 | Kirby 3 Similar is open-sourced software licensed under the MIT license. 164 | 165 | Copyright © 2019 Sonja Broda info@texniq.de https://sonjabroda.com 166 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "texnixe/similar", 3 | "description": "Find similar pages or files based on similarities between fields", 4 | "version": "3.0.1", 5 | "type": "kirby-plugin", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Sonja Broda", 10 | "email": "info@texniq.de" 11 | } 12 | ], 13 | "keywords": [ 14 | "kirby3", 15 | "kirby3-cms", 16 | "kirby3-plugin" 17 | ], 18 | "require": { 19 | "php": ">=8.0.0", 20 | "getkirby/composer-installer": "^1.1" 21 | } 22 | , 23 | "require-dev": { 24 | "roave/security-advisories": "dev-latest" 25 | }, 26 | "scripts": { 27 | "fix": "php-cs-fixer fix" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/hooks.php: -------------------------------------------------------------------------------- 1 | function () { 7 | Similar::flush(); 8 | }, 9 | 'file.*:after' => function () { 10 | Similar::flush(); 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 15 | * @copyright Sonja Broda 16 | * @link https://github.com/texnixe/kirby3-similar 17 | * @license MIT 18 | */ 19 | load([ 20 | 'texnixe\\similar\\similar' => 'lib/Similar.php' 21 | ], __DIR__); 22 | 23 | Kirby::plugin('texnixe/similar', [ 24 | 'options' => [ 25 | 'cache' => option('texnixe.similar.cache', true), 26 | 'expires' => (60 * 24 * 7), // minutes 27 | 'defaults' => [ 28 | 'fields' => 'tags', 29 | 'threshold' => 0.1, 30 | 'delimiter' => ',', 31 | 'languageFilter' => false, 32 | ], 33 | ], 34 | 'pageMethods' => [ 35 | 'similar' => function (array $options = []) { 36 | try { 37 | return (new Similar($this, new Pages(), $options))->getSimilar(); 38 | } catch (Exception $e) { 39 | return new Files(); 40 | } 41 | }, 42 | ], 43 | 'fileMethods' => [ 44 | 'similar' => function (array $options = []) { 45 | try { 46 | return (new Similar($this, new Files(), $options))->getSimilar(); 47 | } catch (Exception $e) { 48 | return new Files(); 49 | } 50 | }, 51 | ], 52 | 'hooks' => require __DIR__ . '/config/hooks.php', 53 | ]); 54 | -------------------------------------------------------------------------------- /lib/Similar.php: -------------------------------------------------------------------------------- 1 | siblings(false); 89 | $this->options = array_merge($defaults, $options); 90 | $this->base = $base; 91 | $this->collection = $collection; 92 | $this->delimiter = $this->options['delimiter']; 93 | $this->fields = $this->options['fields']; 94 | $this->index = $this->options['index']; 95 | $this->languageFilter = $this->options['languageFilter']; 96 | $this->threshold = $this->options['threshold']; 97 | } 98 | 99 | /** 100 | * Returns the cache object 101 | * 102 | * @return Cache 103 | * @throws InvalidArgumentException 104 | */ 105 | protected static function cache(): Cache 106 | { 107 | if (!static::$cache) { 108 | static::$cache = kirby()->cache('texnixe.similar'); 109 | } 110 | 111 | return static::$cache; 112 | } 113 | 114 | /** 115 | * Flushes the cache 116 | * 117 | * @return bool 118 | */ 119 | public static function flush(): bool 120 | { 121 | try { 122 | return static::cache()->flush(); 123 | } catch (InvalidArgumentException) { 124 | return false; 125 | } 126 | } 127 | 128 | 129 | /** 130 | * Returns the similarity index 131 | * 132 | * @param File|Page $item 133 | * @param array $searchItems 134 | * 135 | * @return float 136 | */ 137 | protected function calculateSimilarityIndex(File|Page|User $item, array $searchItems): float 138 | { 139 | $indices = []; 140 | 141 | foreach ($searchItems as $field => $value) { 142 | $itemFieldValues = $item->{$field}()->split($this->delimiter); 143 | $intersection = count(array_intersect($value[$field], $itemFieldValues)); 144 | $union = count(array_unique(array_merge($value[$field], $itemFieldValues))); 145 | if ($union !== 0) { 146 | $indices[] = number_format($intersection / $union * $value['factor'], 5); 147 | } 148 | } 149 | 150 | if (($indexCount = count($indices)) !== 0) { 151 | return array_sum($indices) / $indexCount; 152 | } 153 | 154 | return 0.0; 155 | } 156 | 157 | /** 158 | * Fetches similar pages 159 | * 160 | * @return Files|Pages|Users 161 | * @throws Exception 162 | * @throws InvalidArgumentException 163 | */ 164 | public function getData(): Files|Pages|Users 165 | { 166 | // initialize new collection based on type 167 | $similar = $this->collection; 168 | 169 | // Merge default and user options 170 | $searchItems = $this->getSearchItems(); 171 | 172 | // stop and return an empty collection if the given field doesn't contain any values 173 | if (empty($searchItems)) { 174 | return $similar; 175 | } 176 | 177 | // calculate Jaccard index for each item, filter by given JI threshold and sort 178 | $similar = $this->filterByJaccardIndex($searchItems); 179 | 180 | 181 | // filter collection by current language if $languageFilter set to true 182 | if ($this->languageFilter === true) { 183 | $similar = $this->filterByLanguage($similar); 184 | } 185 | 186 | return $similar; 187 | } 188 | 189 | /** 190 | * Returns collection to search in 191 | * 192 | * @return array 193 | * @throws InvalidArgumentException 194 | * @throws Exception 195 | */ 196 | protected function getSearchItems(): array 197 | { 198 | $searchItems = []; 199 | $fields = $this->fields; 200 | 201 | if (!is_string($fields) && !is_array($fields)) { 202 | throw new InvalidArgumentException('Fields must be provided as string or array'); 203 | } 204 | 205 | if (is_string($fields)) { 206 | $field = $fields; 207 | $searchItems[$field][$field] = $this->base->{$field}()->split($this->delimiter); 208 | $searchItems[$field]['factor'] = 1; 209 | 210 | return $searchItems; 211 | } 212 | 213 | if (A::isAssociative($fields)) { 214 | return $this->searchItemsForAssociativeArray($fields); 215 | } 216 | 217 | return $this->searchItemsForIndexArray($fields); 218 | 219 | } 220 | 221 | /** 222 | * Returns similar pages 223 | * 224 | * @return Files|Pages|Users 225 | * @throws DuplicateException 226 | * @throws Exception 227 | * @throws InvalidArgumentException 228 | * @throws JsonException 229 | */ 230 | public function getSimilar(): Files|Pages|Users 231 | { 232 | // try to get data from the cache, else create new 233 | if (option('texnixe.similar.cache') === true && $response = static::cache()->get(md5($this->version() . $this->base->id() . json_encode($this->options, JSON_THROW_ON_ERROR)))) { 234 | foreach ($response as $key => $data) { 235 | $this->collection->add($key); 236 | } 237 | return $this->collection; 238 | } 239 | 240 | // else fetch new data and store in cache 241 | // make sure we store no old stuff in the cache 242 | if (option('texnixe.similar.cache') === false) { 243 | static::cache()->flush(); 244 | } 245 | $this->collection = $this->getData(); 246 | static::cache()->set( 247 | md5($this->version() . $this->base->id() . json_encode($this->options, JSON_THROW_ON_ERROR)), 248 | $this->collection->toArray(), 249 | option('texnixe.similar.expires') 250 | ); 251 | 252 | return $this->collection; 253 | 254 | } 255 | 256 | /** 257 | * Filters items by Jaccard Index 258 | * 259 | * @param array $searchItems 260 | * 261 | * @return Files|Pages|Users 262 | */ 263 | protected function filterByJaccardIndex(array $searchItems): Files|Pages|Users 264 | { 265 | return $this->index 266 | ->filter(fn ($item) => $this->calculateSimilarityIndex($item, $searchItems) >= $this->threshold) 267 | ->sortBy(fn ($item) => $this->calculateSimilarityIndex($item, $searchItems),'desc'); 268 | } 269 | 270 | /** 271 | * Filters collection by current language if $languageFilter set to true 272 | * 273 | * @param $similar 274 | * 275 | * @return Files|Pages|Users 276 | */ 277 | protected function filterByLanguage($similar): Files|Pages|Users 278 | { 279 | if (kirby()->multilang() === true && ($language = kirby()->language())) { 280 | $similar = $similar->filter(fn ($item) => $item->translation($language->code())->exists()); 281 | } 282 | 283 | return $similar; 284 | } 285 | 286 | /** 287 | * Returns plugin version 288 | * 289 | * @throws DuplicateException 290 | */ 291 | public function version() 292 | { 293 | return Kirby::plugin('texnixe/similar')->version()[0]; 294 | } 295 | 296 | /** 297 | * Return seach items for associative array 298 | * @param array $fields 299 | * @return array 300 | * @throws InvalidArgumentException 301 | */ 302 | private function searchItemsForAssociativeArray(array $fields): array 303 | { 304 | $searchItems = []; 305 | 306 | foreach ($fields as $field => $factor) { 307 | if (is_string($field) === false) { 308 | throw new InvalidArgumentException('Field array must be simple array or associative array'); 309 | } 310 | // only include fields that have values 311 | $values = $this->base->{$field}()->split($this->delimiter); 312 | if (count($values) > 0) { 313 | $searchItems[$field][$field] = $values; 314 | $searchItems[$field]['factor'] = $factor; 315 | } 316 | } 317 | 318 | return $searchItems; 319 | } 320 | 321 | /** 322 | * Return search items for an indexed array 323 | * 324 | * @param array $fields 325 | * @return array 326 | */ 327 | private function searchItemsForIndexArray(array $fields): array 328 | { 329 | $searchItems = []; 330 | 331 | foreach ($fields as $field) { 332 | // only include fields that have values 333 | $values = $this->base->{$field}()->split($this->delimiter); 334 | if (count($values) > 0) { 335 | $searchItems[$field][$field] = $values; 336 | $searchItems[$field]['factor'] = 1; 337 | } 338 | } 339 | 340 | return $searchItems; 341 | 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kirby-similar", 3 | "description": "Kirby 3 Similar plugin", 4 | "author": "Sonja Broda ", 5 | "license": "MIT", 6 | "version": "3.0.1", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/texnixe/kirby3-similar" 10 | }} 11 | --------------------------------------------------------------------------------