From 4738e6c763ef2a3edfc658bb8eb17a2743cd48a4 Mon Sep 17 00:00:00 2001 From: Yegor Bugayenko Date: Tue, 16 Jan 2024 06:24:58 +0300 Subject: [PATCH] #1169 tags --- README.md | 2 +- liquibase/2024/001-initial-schema.xml | 8 ++-- netbout.rb | 20 +++++++-- objects/bout.rb | 9 ++++ objects/message.rb | 10 +++++ objects/query.rb | 36 ++++++++++++---- objects/tag.rb | 52 +++++++++++++++++++++++ objects/tags.rb | 60 +++++++++++++++++++++++++++ test/test_tags.rb | 48 +++++++++++++++++++++ views/inbox.haml | 29 ++++++++++--- views/index.haml | 3 +- 11 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 objects/tag.rb create mode 100644 objects/tags.rb create mode 100644 test/test_tags.rb diff --git a/README.md b/README.md index f11822010..80bccfef4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A user can (both via web interface and RESTful JSON API): * Post an immutable **message** to a bout (can't edit or delete it) * Attach a **flag** to a message * Drop a flag from a message - * Attach an immutable **tag** to a bout with a value (can't detach or modify) + * Put an immutable **tag** to a bout with a value (can't remove or modify) * List messages/bouts by search string A search string is similar to what GitHub uses: diff --git a/liquibase/2024/001-initial-schema.xml b/liquibase/2024/001-initial-schema.xml index fd4bd6b76..34f97c70e 100644 --- a/liquibase/2024/001-initial-schema.xml +++ b/liquibase/2024/001-initial-schema.xml @@ -60,16 +60,14 @@ SOFTWARE. CREATE TABLE tag ( - id SERIAL PRIMARY KEY, bout INT NOT NULL REFERENCES bout(id), - author VARCHAR(32) NOT NULL REFERENCES human(identity), name VARCHAR(256) NOT NULL, value VARCHAR(1024) NOT NULL, - created TIMESTAMPTZ DEFAULT now() NOT NULL + author VARCHAR(32) NOT NULL REFERENCES human(identity), + created TIMESTAMPTZ DEFAULT now() NOT NULL, + PRIMARY KEY(bout, name) ); - CREATE INDEX idx_tag1 ON tag (bout); CREATE INDEX idx_tag2 ON tag (author); - CREATE INDEX idx_tag3 ON tag (name); CREATE INDEX idx_tag4 ON tag (value); CREATE INDEX idx_tag5 ON tag (created); diff --git a/netbout.rb b/netbout.rb index 2c30d5fa4..64ed82799 100644 --- a/netbout.rb +++ b/netbout.rb @@ -111,10 +111,14 @@ get '/inbox' do offset = [(params[:offset] || '0').to_i, 0].max limit = (params[:limit] || '10').to_i - query = params[:q] || '' + query = Nb::Query.new(params[:q] || '') + query.predicate.if_bout do |b| + bout = current_human.bouts.take(b) + raise Nb::Urror, "The bout ##{b.id} doesn't exist" unless bout.exists? + end haml :inbox, locals: merged( title: '/inbox', - query: Nb::Query.new(query), + query: query, limit: limit, offset: offset ) @@ -131,7 +135,7 @@ flash(iri.cut('/start'), "The title can't be empty") if title.nil? bout = current_human.bouts.start(title) response.headers['X-Netbout-Bout'] = bout.id.to_s - flash(iri.cut('/b').append(bout.id), "Bout ##{bout.id} was started") + flash(iri.cut('/b').append(bout.id), "The bout ##{bout.id} started") end get '/b/{id}' do @@ -157,7 +161,15 @@ text = params[:text] msg = bout.post(text) response.headers['X-Netbout-Message'] = msg.id.to_s - flash(iri.cut('/b').append(bout.id), "Message posted to the bout ##{msg.id}") + flash(iri.cut('/b').append(bout.id), "Message ##{msg.id} posted to the bout ##{bout.id}") +end + +post '/tag' do + bout = current_human.bouts.take(params[:bout].to_i) + name = params[:name] + value = params[:value] + bout.tags.put(name, value) + flash(iri.cut('/b').append(bout.id), "Tag '##{name}' put to the bout ##{bout.id}") end get '/terms' do diff --git a/objects/bout.rb b/objects/bout.rb index ac6c4147b..c98bf34b6 100644 --- a/objects/bout.rb +++ b/objects/bout.rb @@ -39,6 +39,10 @@ def initialize(pgsql, identity, id) @id = id end + def exists? + !@pgsql.exec('SELECT * FROM bout WHERE id = $1', [@id]).empty? + end + def post(text) rows = @pgsql.exec( 'INSERT INTO message (author, bout, text) VALUES ($1, $2, $3) RETURNING id', @@ -48,4 +52,9 @@ def post(text) require_relative 'message' Nb::Message.new(@pgsql, @identity, id) end + + def tags + require_relative 'tags' + Nb::Tags.new(@pgsql, @identity, self) + end end diff --git a/objects/message.rb b/objects/message.rb index 285c606b1..e9c608947 100644 --- a/objects/message.rb +++ b/objects/message.rb @@ -43,6 +43,16 @@ def text @pgsql.exec('SELECT text FROM message WHERE id = $1', [@id])[0]['text'] end + def created + @pgsql.exec('SELECT created FROM message WHERE id = $1', [@id])[0]['created'] + end + + def author + author = @pgsql.exec('SELECT author FROM message WHERE id = $1', [@id])[0]['author'] + require_relative 'humans' + Nb::Humans.new(@pgsql).take(author) + end + def bout bout = @pgsql.exec('SELECT bout FROM message WHERE id = $1', [@id])[0]['bout'].to_i require_relative 'bout' diff --git a/objects/query.rb b/objects/query.rb index 68e593989..61395b76e 100644 --- a/objects/query.rb +++ b/objects/query.rb @@ -38,23 +38,25 @@ def initialize(text) def predicate preds = @text.split(/\s+and\s+/i).map do |t| (left, right) = t.split('=') - Eq.new(left.downcase, right) + if right.nil? + Contains.new(left) + else + Eq.new(left.downcase, right) + end end And.new(preds) end + # AND class And def initialize(preds) @preds = preds end - def if_bout + def if_bout(&block) @preds.each do |t| - if t.is_a?(Eq) - t.if_bout do |bout| - yield bout - end - end + next unless t.is_a?(Eq) + t.if_bout(&block) end end @@ -71,6 +73,7 @@ def to_sql end end + # EQ class Eq def initialize(left, right) @left = left @@ -89,4 +92,23 @@ def to_sql "#{@left} = #{@right}" end end + + # CONTAINS + class Contains + def initialize(text) + @text = text + end + + def if_bout + false + end + + def to_s + @text.to_s + end + + def to_sql + "message.text LIKE '%#{@text.gsub('\'', '\\\'')}%'" + end + end end diff --git a/objects/tag.rb b/objects/tag.rb new file mode 100644 index 000000000..9a3a65240 --- /dev/null +++ b/objects/tag.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# (The MIT License) +# +# Copyright (c) 2009-2024 Yegor Bugayenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +require_relative 'nb' +require_relative 'urror' + +# Tag of a bout. +# Author:: Yegor Bugayenko (yegor256@gmail.com) +# Copyright:: Copyright (c) 2009-2024 Yegor Bugayenko +# License:: MIT +class Nb::Tag + attr_reader :name + + def initialize(pgsql, bout, name) + @pgsql = pgsql + raise 'Bout is NULL' if bout.nil? + @bout = bout + raise 'Name is NULL' if name.nil? + @name = name + end + + def exists? + !@pgsql.exec('SELECT * FROM tag WHERE bout=$1 AND name=$2', [@bout.id, @name]).empty? + end + + def value + row = @pgsql.exec('SELECT value FROM tag WHERE bout=$1 AND name=$2', [@bout.id, @name])[0] + raise Nb::Urror, "Tag '#{@name}' not found in the bout ##{@bout.id}" if row.nil? + row['value'] + end +end diff --git a/objects/tags.rb b/objects/tags.rb new file mode 100644 index 000000000..483c32e3a --- /dev/null +++ b/objects/tags.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# (The MIT License) +# +# Copyright (c) 2009-2024 Yegor Bugayenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +require_relative 'nb' +require_relative 'urror' + +# Tags of a bout. +# Author:: Yegor Bugayenko (yegor256@gmail.com) +# Copyright:: Copyright (c) 2009-2024 Yegor Bugayenko +# License:: MIT +class Nb::Tags + def initialize(pgsql, identity, bout) + @pgsql = pgsql + raise 'Identity is NULL' if identity.nil? + @identity = identity + raise 'Bout is NULL' if bout.nil? + @bout = bout + end + + def take(name) + require_relative 'tag' + Nb::Tag.new(@pgsql, @bout, name) + end + + def put(name, value) + @pgsql.exec( + 'INSERT INTO tag (bout, name, author, value) VALUES ($1, $2, $3, $4)', + [@bout.id, name, @identity, value] + ) + take(name) + end + + def each + require_relative 'tag' + @pgsql.exec('SELECT * FROM tag WHERE bout=$1', [@bout.id]).each do |row| + yield take(row['name']) + end + end +end diff --git a/test/test_tags.rb b/test/test_tags.rb new file mode 100644 index 000000000..40cb8bf5f --- /dev/null +++ b/test/test_tags.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# (The MIT License) +# +# Copyright (c) 2009-2024 Yegor Bugayenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +require 'minitest/autorun' +require_relative 'test__helper' +require_relative '../objects/nb' +require_relative '../objects/humans' +require_relative '../objects/bouts' + +# Test of Tags. +# Author:: Yegor Bugayenko (yegor256@gmail.com) +# Copyright:: Copyright (c) 2009-2024 Yegor Bugayenko +# License:: MIT +class Nb::TagsTest < Minitest::Test + def test_puts_tag_to_bout + human = Nb::Humans.new(test_pgsql).take(test_name).create + bouts = human.bouts + bout = bouts.start('hi') + bout.tags.put('a', 'Hello, друг!') + tag = bout.tags.take('a') + assert(tag.exists?) + assert(tag.value.start_with?('Hello')) + bout.tags.each do |t| + assert(t.exists?) + end + end +end diff --git a/views/inbox.haml b/views/inbox.haml index 2d996b9f6..456dad5a6 100644 --- a/views/inbox.haml +++ b/views/inbox.haml @@ -1,25 +1,44 @@ +- the_bout = nil +- query.predicate.if_bout { |b| the_bout = b } + %form{action: iri, method: 'get'} %fieldset %input{name: 'q', type: 'text', value: query.predicate.to_s} %button{type: 'submit'} Search -- query.predicate.if_bout do |b| +- unless the_bout.nil? %form{action: iri.cut('/post'), method: 'post'} %fieldset - %input{name: 'bout', type: 'hidden', value: b} + %input{name: 'bout', type: 'hidden', value: the_bout} %textarea{name: 'text', placeholder: 'Post a message...', tabindex: 1, autofocus: 1} %br %button{type: 'submit', tabindex: 2} Post +- unless the_bout.nil? + %form{action: iri.cut('/tag'), method: 'post'} + %fieldset + %input{name: 'bout', type: 'hidden', value: the_bout} + %input{name: 'name', type: 'text', size: 8, placeholder: 'Tag', tabindex: 3} + %input{name: 'value', type: 'text', size: 20, placeholder: 'Value', tabindex: 4} + %button{type: 'submit', tabindex: 5} + Put + %p + Tags: + - current_human.bouts.take(the_bout).tags.each do |tag| + %code= "#{tag.name}=#{tag.value}" + - total = 0 - current_human.search(query, offset, limit).each do |msg| %p - %a{href: iri.cut('/b').append(msg.bout.id)} - = "##{msg.bout.id}" - = '/' + - if the_bout.nil? + %a{href: iri.cut('/b').append(msg.bout.id)}= "##{msg.bout.id}" + = '/' = "##{msg.id}" + = "by @#{msg.author.identity}" + = msg.created + %br = msg.text - total += 1 diff --git a/views/index.haml b/views/index.haml index 976fa786d..07b87b50a 100644 --- a/views/index.haml +++ b/views/index.haml @@ -1,5 +1,4 @@ %p You have to - %a{href: github_login_link} - login + %a{href: github_login_link}= 'login' first. \ No newline at end of file