make_taggable 0.7.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/CONTRIBUTING.md +65 -22
- data/LICENSE.md +18 -17
- data/README.md +73 -444
- data/UPGRADING.md +28 -2
- data/docs/caching.md +93 -0
- data/docs/configuration.md +108 -0
- data/docs/contexts.md +147 -0
- data/docs/database.md +106 -0
- data/docs/getting-started.md +114 -0
- data/docs/migrating-from-aato.md +67 -0
- data/docs/ownership.md +111 -0
- data/docs/parsers.md +109 -0
- data/docs/querying.md +163 -0
- data/docs/tag-clouds.md +74 -0
- data/lib/make_taggable/default_parser.rb +45 -32
- data/lib/make_taggable/engine.rb +6 -0
- data/lib/make_taggable/generic_parser.rb +30 -4
- data/lib/make_taggable/tag.rb +121 -19
- data/lib/make_taggable/tag_list.rb +77 -24
- data/lib/make_taggable/taggable/cache.rb +61 -11
- data/lib/make_taggable/taggable/collection.rb +102 -30
- data/lib/make_taggable/taggable/core.rb +154 -34
- data/lib/make_taggable/taggable/ownership.rb +82 -4
- data/lib/make_taggable/taggable/related.rb +66 -3
- data/lib/make_taggable/taggable/tag_list_type.rb +8 -0
- data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +10 -0
- data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +15 -0
- data/lib/make_taggable/taggable/tagged_with_query.rb +17 -0
- data/lib/make_taggable/taggable.rb +34 -33
- data/lib/make_taggable/tagger.rb +73 -12
- data/lib/make_taggable/tagging.rb +30 -3
- data/lib/make_taggable/tags_helper.rb +22 -1
- data/lib/make_taggable/utils.rb +40 -5
- data/lib/make_taggable/version.rb +8 -1
- data/lib/make_taggable.rb +153 -8
- data/make_taggable.gemspec +13 -20
- metadata +50 -165
- data/.dummyrc +0 -17
- data/.github/workflows/ci.yml +0 -140
- data/.github/workflows/standard-ci.yml +0 -27
- data/.gitignore +0 -17
- data/.rspec +0 -3
- data/Appraisals +0 -15
- data/Gemfile +0 -16
- data/Rakefile +0 -13
- data/gemfiles/rails_5.gemfile +0 -9
- data/gemfiles/rails_6.gemfile +0 -9
- data/gemfiles/rails_6_1.gemfile +0 -9
- data/gemfiles/rails_master.gemfile +0 -9
- data/lib/tasks/setup_test_db.rake +0 -8
data/docs/ownership.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Tag ownership
|
|
2
|
+
|
|
3
|
+
A tag can be applied *by* someone. That someone is a tagger, and the tags they applied are owned by
|
|
4
|
+
them. It is how you build "your tags" alongside everyone else's on the same record.
|
|
5
|
+
|
|
6
|
+
## Declaring a tagger
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class User < ApplicationRecord
|
|
10
|
+
make_tagger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Photo < ApplicationRecord
|
|
14
|
+
make_taggable :locations
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`make_tagger` adds two associations to the model: `owned_taggings` and `owned_tags`.
|
|
19
|
+
|
|
20
|
+
## Applying owned tags
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
user.tag(photo, with: "paris, normandy", on: :locations)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`with` and `on` are both required — `tag` raises without them. The taggable is saved for you unless
|
|
27
|
+
you pass `skip_save: true`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
user.tag(photo, with: "paris", on: :locations, skip_save: true)
|
|
31
|
+
photo.save # when you're ready
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
By default you may tag in a context the model does not declare. Pass `force: false` to require a
|
|
35
|
+
declared context:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
user.tag(photo, with: "paris", on: :undeclared, force: false)
|
|
39
|
+
# => RuntimeError: No context :undeclared defined in Photo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## The one thing that surprises everyone
|
|
43
|
+
|
|
44
|
+
**`tag_list` never returns owned tags.** It returns only tags with no owner.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
photo.tag_list # => [] <- not what most people expect
|
|
48
|
+
photo.all_tags_list # => ["paris", "normandy"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The same split applies per context: `location_list` excludes owned tags, `all_locations_list`
|
|
52
|
+
includes them. If every tag on a record is applied by a tagger, `tag_list` will always look empty.
|
|
53
|
+
|
|
54
|
+
So: use `all_*_list` to display what a record is tagged with, and `*_list` only when you
|
|
55
|
+
specifically mean the unowned tags.
|
|
56
|
+
|
|
57
|
+
## Reading owned tags
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
photo.locations_from(user) # => ["paris", "normandy"]
|
|
61
|
+
photo.owner_tags_on(user, :locations) # => [#<MakeTaggable::Tag name: "paris">, ...]
|
|
62
|
+
photo.owner_tags_on(nil, :locations) # => every tag on the photo, owned or not
|
|
63
|
+
|
|
64
|
+
user.owned_taggings
|
|
65
|
+
user.owned_tags
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Find records by who tagged them:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Photo.tagged_with("paris", on: :locations, owned_by: user)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Owned tags are replaced, not appended
|
|
75
|
+
|
|
76
|
+
Assigning an owner's tags overwrites everything that owner previously applied in that context:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
user.tag(photo, with: "paris", on: :locations)
|
|
80
|
+
user.tag(photo, with: "normandy", on: :locations)
|
|
81
|
+
|
|
82
|
+
photo.locations_from(user) # => ["normandy"] <- "paris" is gone
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
To add to what is already there, read the current list and write it back with the addition:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
def add_owned_tag(photo, user, tag)
|
|
89
|
+
current = photo.locations_from(user)
|
|
90
|
+
user.tag(photo, with: (current + [tag]).join(", "), on: :locations)
|
|
91
|
+
photo.save
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
And to remove one:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
def remove_owned_tag(photo, user, tag)
|
|
99
|
+
current = photo.locations_from(user)
|
|
100
|
+
user.tag(photo, with: (current - [tag]).join(", "), on: :locations)
|
|
101
|
+
photo.save
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Joining with `", "` matches the default delimiter. If you have configured a different one, use
|
|
106
|
+
`MakeTaggable.glue` instead of a literal.
|
|
107
|
+
|
|
108
|
+
## Ordering
|
|
109
|
+
|
|
110
|
+
On a model declared with `make_ordered_taggable`, owned tags keep the order they were applied in,
|
|
111
|
+
the same as unowned ones. See [contexts.md](contexts.md).
|
data/docs/parsers.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Parsers and delimiters
|
|
2
|
+
|
|
3
|
+
Every string that becomes a tag list goes through a parser. The default splits on commas and
|
|
4
|
+
understands quoting.
|
|
5
|
+
|
|
6
|
+
## The default parser
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
MakeTaggable::DefaultParser.new("One , Two, Three").parse
|
|
10
|
+
# => ["One", "Two", "Three"]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A tag containing the delimiter can be quoted, with single or double quotes:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
MakeTaggable::DefaultParser.new('"Ruby, Rails", Hotwire').parse
|
|
17
|
+
# => ["Ruby, Rails", "Hotwire"]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`TagList#to_s` quotes on the way back out, so a list survives a round trip through a text field:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
MakeTaggable::TagList.new("Round", "Square,Cube").to_s
|
|
24
|
+
# => 'Round, "Square,Cube"'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Delimiters
|
|
28
|
+
|
|
29
|
+
The delimiter defaults to `","`. Set one, or several:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
MakeTaggable.delimiter = ";"
|
|
33
|
+
MakeTaggable.delimiter = [",", "|", ";"]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Delimiters are literal strings.** Regular expression metacharacters are escaped for you, so
|
|
37
|
+
`"|"`, `"."` and `"("` all mean themselves:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
MakeTaggable.delimiter = [",", "|"]
|
|
41
|
+
MakeTaggable::DefaultParser.new("a|b,c").parse # => ["a", "b", "c"]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Changed in 1.0.** Delimiters used to be interpolated into a pattern unescaped, which meant you
|
|
45
|
+
> had to pass `'\|'` to split on a pipe, and a `"."` delimiter silently matched every character and
|
|
46
|
+
> discarded the whole list. If you are passing pre-escaped delimiters, unescape them.
|
|
47
|
+
|
|
48
|
+
`MakeTaggable.delimiter=` is deprecated in favour of a parser, and warns when Active Record has a
|
|
49
|
+
logger. It still works, and reading `MakeTaggable.delimiter` is not deprecated.
|
|
50
|
+
|
|
51
|
+
Related: `MakeTaggable.glue` is what tags are joined with for display. It is the first configured
|
|
52
|
+
delimiter, with a trailing space added if it has none, which is why a comma delimiter renders as
|
|
53
|
+
`"one, two"`.
|
|
54
|
+
|
|
55
|
+
## Writing a parser
|
|
56
|
+
|
|
57
|
+
Subclass `MakeTaggable::GenericParser` and implement `parse`, returning a `TagList`. The raw input
|
|
58
|
+
is in `@tag_list`.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
class PipeParser < MakeTaggable::GenericParser
|
|
62
|
+
def parse
|
|
63
|
+
MakeTaggable::TagList.new.tap do |tag_list|
|
|
64
|
+
tag_list.add @tag_list.split("|")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use it for one call:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
user.tag_list.add("north|west", parser: PipeParser)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For one list:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
user.tag_list.parser = PipeParser
|
|
80
|
+
user.tag_list.add("north|west")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or everywhere:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
MakeTaggable.default_parser = PipeParser
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The global setting is what `tag_list=` uses, so it is the only one of the three that changes how
|
|
90
|
+
assignment behaves:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
MakeTaggable.default_parser = PipeParser
|
|
94
|
+
user.tag_list = "east|south"
|
|
95
|
+
user.tag_list # => ["east", "south"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`GenericParser` is itself a working parser — it splits on commas, ignoring surrounding whitespace
|
|
99
|
+
and empty entries — so subclass it when you want a different format, and use it directly when you
|
|
100
|
+
want comma splitting without the quoting rules.
|
|
101
|
+
|
|
102
|
+
## What happens after parsing
|
|
103
|
+
|
|
104
|
+
Whatever a parser returns is cleaned before it is stored: entries are converted to strings,
|
|
105
|
+
stripped of surrounding whitespace, and blanks and duplicates are dropped. Two settings also apply
|
|
106
|
+
here — `force_lowercase` and `force_parameterize` — see [configuration.md](configuration.md).
|
|
107
|
+
|
|
108
|
+
Duplicate detection follows `strict_case_match`: with it off, `"Ruby"` and `"ruby"` are the same
|
|
109
|
+
tag; with it on, they are two.
|
data/docs/querying.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Querying
|
|
2
|
+
|
|
3
|
+
## `tagged_with`
|
|
4
|
+
|
|
5
|
+
`tagged_with` returns a relation, so it chains with your own scopes, with `order`, and with
|
|
6
|
+
pagination.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
User.tagged_with("awesome").by_join_date.limit(20)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
By default every tag given must be present:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
User.tagged_with(["awesome", "cool"]) # carries awesome AND cool
|
|
16
|
+
User.tagged_with(["awesome", "cool"], any: true) # carries awesome OR cool
|
|
17
|
+
User.tagged_with(["awesome", "cool"], exclude: true) # carries NEITHER
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Passing nothing matches nothing. `User.tagged_with([])` and `User.tagged_with("")` both return an
|
|
21
|
+
empty relation rather than every record — worth knowing when the tags come from user input.
|
|
22
|
+
|
|
23
|
+
### Options
|
|
24
|
+
|
|
25
|
+
| Option | Effect |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `:any` | Match records carrying at least one of the tags |
|
|
28
|
+
| `:exclude` | Match records carrying none of the tags |
|
|
29
|
+
| `:match_all` | Match records carrying only these tags and no others |
|
|
30
|
+
| `:wild` | Match tags *containing* the given text, i.e. `%awesome%` |
|
|
31
|
+
| `:on` | Restrict to one context |
|
|
32
|
+
| `:owned_by` | Restrict to tags applied by one tagger |
|
|
33
|
+
| `:order_by_matching_tag_count` | With `:any`, order by how many tags matched, most first |
|
|
34
|
+
| `:start_at` | Only tags applied after this time |
|
|
35
|
+
| `:end_at` | Only tags applied before this time |
|
|
36
|
+
|
|
37
|
+
`:wild` combines with `:any` or `:exclude`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
User.tagged_with(["awe", "co"], any: true, wild: true)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Contexts are matched one call at a time, so chain to combine them:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
User
|
|
47
|
+
.tagged_with(["awesome", "cool"], on: :tags, any: true)
|
|
48
|
+
.tagged_with(["smart", "shy"], on: :skills, any: true)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Case sensitivity
|
|
52
|
+
|
|
53
|
+
Tag matching is case insensitive by default: `tagged_with("Ruby")` finds records tagged `"ruby"`.
|
|
54
|
+
Set `MakeTaggable.strict_case_match = true` to match exactly. See
|
|
55
|
+
[configuration.md](configuration.md), and the SQLite note in [database.md](database.md) — SQLite
|
|
56
|
+
cannot change the case of non-ASCII characters without an extension.
|
|
57
|
+
|
|
58
|
+
## Counting tags
|
|
59
|
+
|
|
60
|
+
`tag_counts_on` returns tags with a `count` attribute, which is what tag clouds are built from.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Book.tag_counts_on(:genres)
|
|
64
|
+
book.tag_counts_on(:genres) # just this book's genres
|
|
65
|
+
Book.genre_counts # the same, generated per context
|
|
66
|
+
Book.top_genres(10) # the ten most used, most first
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`count` is an attribute on the returned tags, not a method call on the relation:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Book.tag_counts_on(:genres).each do |tag|
|
|
73
|
+
puts "#{tag.name}: #{tag.count}"
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `all_tags` and `all_tag_counts`
|
|
78
|
+
|
|
79
|
+
The generated methods above are wrappers around these two, which take the full option set:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Book.all_tag_counts(
|
|
83
|
+
on: :genres,
|
|
84
|
+
at_least: 5,
|
|
85
|
+
at_most: 100,
|
|
86
|
+
order: "count desc",
|
|
87
|
+
limit: 20,
|
|
88
|
+
start_at: 1.month.ago
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Option | `all_tags` | `all_tag_counts` | Effect |
|
|
93
|
+
|---|:--:|:--:|---|
|
|
94
|
+
| `:on` | ✓ | ✓ | Restrict to one context |
|
|
95
|
+
| `:start_at` | ✓ | ✓ | Only tags applied after this time |
|
|
96
|
+
| `:end_at` | ✓ | ✓ | Only tags applied before this time |
|
|
97
|
+
| `:conditions` | ✓ | ✓ | SQL conditions added to the tag query |
|
|
98
|
+
| `:order` | ✓ | ✓ | SQL to order by |
|
|
99
|
+
| `:limit` | ✓ | ✓ | Most tags to return |
|
|
100
|
+
| `:at_least` | | ✓ | Skip tags used fewer times than this |
|
|
101
|
+
| `:at_most` | | ✓ | Skip tags used more times than this |
|
|
102
|
+
|
|
103
|
+
Use `all_tags` when you only need the names: it skips the join onto the taggable table, so it is
|
|
104
|
+
noticeably cheaper on a large table.
|
|
105
|
+
|
|
106
|
+
## Related records
|
|
107
|
+
|
|
108
|
+
Records sharing tags with this one, ordered by how many tags matched:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
tom.skill_list # => ["hacking", "jogging", "diving"]
|
|
112
|
+
bobby.skill_list # => ["jogging", "diving"]
|
|
113
|
+
frankie.skill_list # => ["hacking"]
|
|
114
|
+
|
|
115
|
+
tom.find_related_skills # => [bobby, frankie]
|
|
116
|
+
bobby.find_related_skills # => [tom]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Ignore some tags, or search a different model:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
tom.find_related_skills(ignore: ["jogging"])
|
|
123
|
+
tom.find_related_skills_for(Company)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
To match one context's tags against a different context's, use `find_matching_contexts`:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# users whose :interests match this user's :skills
|
|
130
|
+
user.find_matching_contexts(:skills, :interests)
|
|
131
|
+
user.find_matching_contexts_for(Company, :skills, :markets)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Both return records carrying a `count` attribute holding the number of matching tags.
|
|
135
|
+
|
|
136
|
+
## Tags directly
|
|
137
|
+
|
|
138
|
+
`MakeTaggable::Tag` is an ordinary model, so query it when you want the vocabulary rather than the
|
|
139
|
+
records:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
MakeTaggable::Tag.most_used # default limit 20
|
|
143
|
+
MakeTaggable::Tag.least_used(10)
|
|
144
|
+
MakeTaggable::Tag.named("ruby") # exact, honouring case sensitivity
|
|
145
|
+
MakeTaggable::Tag.named_any(%w[ruby rails])
|
|
146
|
+
MakeTaggable::Tag.named_like("rub") # contains
|
|
147
|
+
MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`most_used` and `least_used` read the `taggings_count` counter cache, so they do not aggregate at
|
|
151
|
+
query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
|
|
152
|
+
scopes will be wrong.
|
|
153
|
+
|
|
154
|
+
## Performance notes
|
|
155
|
+
|
|
156
|
+
- `tagged_with` with several tags and no `:any` adds one join per tag. Matching a dozen tags in a
|
|
157
|
+
single call generates a dozen joins; prefer `any: true` where the semantics allow it.
|
|
158
|
+
- `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
|
|
159
|
+
page of results and expensive across a whole table.
|
|
160
|
+
- `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
|
|
161
|
+
needed.
|
|
162
|
+
- Caching a rendered list in a column avoids loading tags to display them. See
|
|
163
|
+
[caching.md](caching.md).
|
data/docs/tag-clouds.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Tag clouds
|
|
2
|
+
|
|
3
|
+
A tag cloud sizes each tag by how often it is used. MakeTaggable gives you the counts and a helper
|
|
4
|
+
that maps them onto CSS classes.
|
|
5
|
+
|
|
6
|
+
## Getting the counts
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class BooksController < ApplicationController
|
|
10
|
+
def tag_cloud
|
|
11
|
+
@tags = Book.tag_counts_on(:genres)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`tag_counts_on` returns tags carrying a `count` attribute. Limit it to the tags worth showing:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
@tags = Book.all_tag_counts(on: :genres, at_least: 3, limit: 50, order: "count desc")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Counts also work per record and per association:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
book.tag_counts_on(:genres)
|
|
26
|
+
user.books.tag_counts_on(:genres)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## The helper
|
|
30
|
+
|
|
31
|
+
`MakeTaggable::TagsHelper` is mixed into Action View automatically, so `tag_cloud` is available in
|
|
32
|
+
any view. It yields each tag with the CSS class matching its frequency, smallest class first:
|
|
33
|
+
|
|
34
|
+
```erb
|
|
35
|
+
<% tag_cloud(@tags, %w[cloud1 cloud2 cloud3 cloud4]) do |tag, css_class| %>
|
|
36
|
+
<%= link_to tag.name, books_path(tag: tag.name), class: css_class %>
|
|
37
|
+
<% end %>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```css
|
|
41
|
+
.cloud1 { font-size: 1.0em; }
|
|
42
|
+
.cloud2 { font-size: 1.2em; }
|
|
43
|
+
.cloud3 { font-size: 1.4em; }
|
|
44
|
+
.cloud4 { font-size: 1.6em; }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The classes are spread across the range between the least and most used tag in the set you pass, so
|
|
48
|
+
the same tag can land in a different band depending on what it is shown alongside.
|
|
49
|
+
|
|
50
|
+
Two things to know:
|
|
51
|
+
|
|
52
|
+
- `tag_cloud` requires a block, and returns an empty array without calling it when `@tags` is empty.
|
|
53
|
+
- It reads `taggings_count`, not the `count` attribute from the query. That means it reflects how
|
|
54
|
+
often a tag is used **overall**, not how often within the scope you counted. For a cloud of one
|
|
55
|
+
user's tags, sizing by global popularity is usually not what you want — build the markup yourself
|
|
56
|
+
from `tag.count` in that case:
|
|
57
|
+
|
|
58
|
+
```erb
|
|
59
|
+
<% max = @tags.map(&:count).max.to_f %>
|
|
60
|
+
<% @tags.each do |tag| %>
|
|
61
|
+
<%= link_to tag.name, books_path(tag: tag.name),
|
|
62
|
+
style: "font-size: #{1.0 + (tag.count / max)}em" %>
|
|
63
|
+
<% end %>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Using it outside Action View
|
|
67
|
+
|
|
68
|
+
Include the module wherever you need it:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
module PostsHelper
|
|
72
|
+
include MakeTaggable::TagsHelper
|
|
73
|
+
end
|
|
74
|
+
```
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MakeTaggable
|
|
2
4
|
##
|
|
3
|
-
#
|
|
5
|
+
# The parser the library uses unless told otherwise.
|
|
6
|
+
#
|
|
7
|
+
# It splits on the configured delimiter, or delimiters, and understands quoting: a tag wrapped in
|
|
8
|
+
# single or double quotes may contain the delimiter itself.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# MakeTaggable::DefaultParser.new("One , Two, Three").parse
|
|
12
|
+
# # => ["One", "Two", "Three"]
|
|
13
|
+
#
|
|
14
|
+
# @example A tag containing the delimiter
|
|
15
|
+
# MakeTaggable::DefaultParser.new('"Ruby, Rails", Hotwire').parse
|
|
16
|
+
# # => ["Ruby, Rails", "Hotwire"]
|
|
4
17
|
#
|
|
5
|
-
# Example:
|
|
6
|
-
# tag_list = MakeTaggable::DefaultParser.parse("One , Two, Three")
|
|
7
|
-
# tag_list # ["One", "Two", "Three"]
|
|
8
18
|
class DefaultParser < GenericParser
|
|
19
|
+
##
|
|
20
|
+
# Splits the input into tags, honouring quotes and every configured delimiter.
|
|
21
|
+
#
|
|
22
|
+
# @return [MakeTaggable::TagList] the parsed tags
|
|
23
|
+
#
|
|
9
24
|
def parse
|
|
10
25
|
string = @tag_list
|
|
11
26
|
|
|
@@ -33,41 +48,39 @@ module MakeTaggable
|
|
|
33
48
|
end
|
|
34
49
|
end
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
##
|
|
52
|
+
# The configured delimiters as a regular expression source, ready to interpolate into a pattern.
|
|
53
|
+
#
|
|
54
|
+
# @return [String]
|
|
55
|
+
#
|
|
56
|
+
# @api private
|
|
57
|
+
#
|
|
37
58
|
def delimiter
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
# Delimiters are literal strings, so any regular expression metacharacter
|
|
60
|
+
# they contain has to be escaped before it reaches a pattern. Regexp.union
|
|
61
|
+
# handles both the escaping and the alternation between multiple
|
|
62
|
+
# delimiters.
|
|
63
|
+
Regexp.union(Array(MakeTaggable.delimiter)).source
|
|
43
64
|
end
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# (?= # Tag end delimiter (not consumed; is zero-length lookahead)
|
|
53
|
-
# #{delimiter}\s* | # Either a delimiter optionally followed by whitespace or
|
|
54
|
-
# \z # string end
|
|
55
|
-
# )
|
|
66
|
+
##
|
|
67
|
+
# Matches a double-quoted tag, bounded by the start of the string or a delimiter.
|
|
68
|
+
#
|
|
69
|
+
# @return [Regexp]
|
|
70
|
+
#
|
|
71
|
+
# @api private
|
|
72
|
+
#
|
|
56
73
|
def double_quote_pattern
|
|
57
74
|
/(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
|
|
58
75
|
end
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
# (?= # Tag end delimiter (not consumed; is zero-length lookahead)
|
|
68
|
-
# #{delimiter}\s* | d # Either a delimiter optionally followed by whitespace or
|
|
69
|
-
# \z # string end
|
|
70
|
-
# )
|
|
77
|
+
##
|
|
78
|
+
# Matches a single-quoted tag, bounded by the start of the string or a delimiter.
|
|
79
|
+
#
|
|
80
|
+
# @return [Regexp]
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
#
|
|
71
84
|
def single_quote_pattern
|
|
72
85
|
/(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
|
|
73
86
|
end
|
data/lib/make_taggable/engine.rb
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MakeTaggable
|
|
2
4
|
##
|
|
3
|
-
#
|
|
5
|
+
# The base class for tag parsers, and a working parser in its own right: it splits on commas and
|
|
6
|
+
# ignores surrounding whitespace.
|
|
7
|
+
#
|
|
8
|
+
# Subclass it to accept a different format, and point {MakeTaggable::Configuration#default_parser}
|
|
9
|
+
# at the subclass, or pass it per call as the `:parser` option to {MakeTaggable::TagList#add}.
|
|
10
|
+
#
|
|
11
|
+
# @example A parser splitting on pipes
|
|
12
|
+
# class PipeParser < MakeTaggable::GenericParser
|
|
13
|
+
# def parse
|
|
14
|
+
# MakeTaggable::TagList.new.tap do |tag_list|
|
|
15
|
+
# tag_list.add @tag_list.split("|")
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
4
19
|
#
|
|
5
|
-
# Example:
|
|
6
|
-
# tag_list = MakeTaggable::GenericParser.new.parse("One , Two, Three")
|
|
7
|
-
# tag_list # ["One", "Two", "Three"]
|
|
8
20
|
class GenericParser
|
|
21
|
+
##
|
|
22
|
+
# Holds the input until {#parse} is called.
|
|
23
|
+
#
|
|
24
|
+
# @param tag_list [String, Array<String>, MakeTaggable::TagList] the tag input to parse
|
|
25
|
+
# @return [MakeTaggable::GenericParser]
|
|
26
|
+
#
|
|
9
27
|
def initialize(tag_list)
|
|
10
28
|
@tag_list = tag_list
|
|
11
29
|
end
|
|
12
30
|
|
|
31
|
+
##
|
|
32
|
+
# Splits the input on commas.
|
|
33
|
+
#
|
|
34
|
+
# @return [MakeTaggable::TagList] the parsed tags
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# MakeTaggable::GenericParser.new("One , Two, Three").parse # => ["One", "Two", "Three"]
|
|
38
|
+
#
|
|
13
39
|
def parse
|
|
14
40
|
TagList.new.tap do |tag_list|
|
|
15
41
|
tag_list.add @tag_list.split(",").map(&:strip).reject(&:empty?)
|