diff options
90 files changed, 2733 insertions, 1663 deletions
diff --git a/core/config.rb b/core/config.rb index aa5f3eb5..eb3d951f 100644 --- a/core/config.rb +++ b/core/config.rb @@ -47,6 +47,6 @@ module CHIConfig NeverRetrieveOverlappedMumble = false # このソフトのバージョン。 - VERSION = [3,4,6,9999] + VERSION = [3,5,0,0] end diff --git a/core/configloader.rb b/core/configloader.rb index f0d4197e..0dd98021 100644 --- a/core/configloader.rb +++ b/core/configloader.rb @@ -81,6 +81,16 @@ module ConfigLoader end + # キーに対応する値が存在するかを調べる。 + # 値が設定されていれば、それが _nil_ や _false_ であっても _true_ を返す + # ==== Args + # [key] Symbol キー + # ==== Return + # [true] 存在する + # [false] 存在しない + def include?(key) + @@configloader_cache.has_key? configloader_key(key) end + # _key_ に対応するオブジェクトを取り出す。 # _key_ が存在しない場合は nil か _ifnone_ を返す def at(key, ifnone=nil) diff --git a/core/directmessage.rb b/core/directmessage.rb new file mode 100644 index 00000000..5efe5541 --- /dev/null +++ b/core/directmessage.rb @@ -0,0 +1,141 @@ +miquire :core, 'retriever' + +module Mikutter; end + +module Mikutter::Twitter + class DirectMessage < Retriever::Model + + register :twitter_direct_message, + name: "Direct Message" + + field.int :id, required: true # ID + field.string :text, required: true # Message description + field.has :user, User, required: true # Send by user + field.has :sender, User, required: true # Send by user (old) + field.has :recipient, User, required: true # Received by user + field.bool :exact # true if complete data + field.time :created # posted time + + entity_class Retriever::Entity::TwitterEntity + + def self.memory + @memory ||= DirectMessageMemory.new end + + def mentioned_by_me? + false + end + + def favorite(_) + # Intentionally blank + end + + def favorite? + false + end + + def favorited_by + [] + end + + def favoritable? + false + end + + def retweet + # Intentionally blank + end + + def retweet? + nil + end + + def retweeted? + false + end + + def retweeted_by + [] + end + + def retweetable? + false + end + + def retweet_source(_=nil) + nil + end + + def quoting? + false + end + + def has_receive_message? + false + end + + def to_show + @to_show ||= self[:text].gsub(/&(gt|lt|quot|amp);/){|m| {'gt' => '>', 'lt' => '<', 'quot' => '"', 'amp' => '&'}[$1] }.freeze + end + + def to_message + self + end + alias :message :to_message + + def system? + false + end + + def created + self[:created] + end + + def modified + self[:created] + end + + def from_me? + return false if system? + Service.map(&:user_obj).include?(self[:user]) + end + + def to_me? + true + end + + def user + self[:user] + end + + def idname + user[:idname] + end + + def post(args, &block) + Service.primary.send_direct_message({:text => args[:message], :user => self[:user]}, &block) + end + + def repliable? + true + end + + def perma_link + nil + end + + def receive_user_screen_names + [self[:recipient].idname] + end + + def ancestors(force_retrieve=false) + [self] + end + + def ancestor(force_retrieve=false) + ancestors(force_retrieve).last + end + end + + class DirectMessageMemory < Retriever::Model::Memory; end + +end diff --git a/core/entity.rb b/core/entity.rb deleted file mode 100644 index f2759b89..00000000 --- a/core/entity.rb +++ /dev/null @@ -1,289 +0,0 @@ -# -*- coding: utf-8 -*- -miquire :core, 'message' -miquire :core, 'userconfig' -miquire :lib, 'addressable/uri' - -class Message::Entity - ESCAPE_RULE = {'&' => '&'.freeze ,'>' => '>'.freeze, '<' => '<'.freeze}.freeze - UNESCAPE_RULE = ESCAPE_RULE.invert.freeze - REGEXP_EACH_CHARACTER = //u.freeze - REGEXP_ENTITY_ENCODE_TARGET = Regexp.union(*ESCAPE_RULE.keys.map(&Regexp.method(:escape))).freeze - REGEXP_ENTITY_DECODE_TARGET = Regexp.union(*ESCAPE_RULE.values.map(&Regexp.method(:escape))).freeze - - include Enumerable - - attr_reader :message - - def self.addlinkrule(slug, regexp=nil, filter_id=nil, &callback) - slug = slug.to_sym - Plugin.call(:entity_linkrule_added, { slug: slug, filter_id: filter_id, regexp: regexp, callback: callback }.freeze) - # Gtk::IntelligentTextview.addlinkrule(regexp, lambda{ |seg, tv| callback.call(face: seg, url: seg, textview: tv) }) if regexp - self end - - def self.on_entity_linkrule_added(linkrule) - @@linkrule[linkrule[:slug]] = linkrule - self end - - def self.filter(slug, &filter) - if @@filter.has_key?(slug) - parent = @@filter[slug] - @@filter[slug] = filter_wrap{ |s| filter.call(parent.call(s)) } - else - @@filter[slug] = filter_wrap &filter end - self end - - def self.filter_wrap(&filter) - ->(s) { - result = filter.call(s) - [:url, :face].each{ |key| - if defined? result[:message] - raise InvalidEntityError.new("entity key :#{key} required. but not exist", result[:message]) unless result[key] - else - raise RuntimeError, "entity key :#{key} required. but not exist" end } - result } end - - def self.refresh - @@linkrule = {} - @@filter = Hash.new(filter_wrap(&ret_nth)) - filter(:urls){ |segment| - segment[:face] = (segment[:display_url] or segment[:url]) - segment[:url] = (segment[:expanded_url] or segment[:url]) - segment } - - filter(:media){ |segment| - case segment[:video_info] and segment[:type] - when 'video' - variant = Array(segment[:video_info][:variants]) - .select{|v|v[:content_type] == "video/mp4"} - .sort_by{|v|v[:bitrate]} - .last - segment[:face] = "#{segment[:display_url]} (%.1fs)" % (segment[:video_info][:duration_millis]/1000.0) - segment[:url] = variant[:url] - when 'animated_gif' - variant = Array(segment[:video_info][:variants]) - .select{|v|v[:content_type] == "video/mp4"} - .sort_by{|v|v[:bitrate]} - .last - segment[:face] = "#{segment[:display_url]} (GIF)" - segment[:url] = variant[:url] - else - segment[:face] = segment[:display_url] - segment[:url] = segment[:media_url] - end - segment } - - filter(:hashtags){ |segment| - segment[:face] ||= "#"+segment[:text] - segment[:url] ||= "#"+segment[:text] - segment } - - filter(:user_mentions){ |segment| - segment[:face] ||= "@"+segment[:screen_name] - segment[:url] ||= "@"+segment[:screen_name] - segment } - end - - def initialize(message) - @message = message - @generate_value = _generate_value || [] end - - def each - to_a.each(&Proc.new) - end - - def reverse_each - to_a.reverse.each(&Proc.new) - end - - # [{range: リンクを貼る場所のRange, face: 表示文字列, url:リンク先}, ...] の配列を返す - # face: TLに印字される文字列。 - # url: 実際のリンク先。本当にURLになるかはリンクの種類に依存する。 - # 例えばハッシュタグ "#mikutter" の場合はこの内容は "mikutter" になる。 - def to_a - generate_value end - - # entityフィルタを適用した状態のMessageの本文を返す - def to_s - segment_splitted.map{ |s| - if s.is_a? Hash - s[:face] - else - s end }.join end - - # 外部からエンティティを書き換える。 - # これでエンティティが書き換えられた場合、イベントで書き換えが通知される。 - # また、エンティティの範囲が被った場合それを削除する - # ==== Args - # [addition] Hash 以下の要素を持つ配列 - # - :slug (required) :: Symbol エンティティの種類。:urls 等 - # - :url (required) :: String 実際のクエリ(リンク先URL等) - # - :face (required) :: String 表示する文字列 - # - :range (required) :: Range message上の置き換える範囲 - # - :message :: Message 親Message - # ==== Return - # self - def add(addition) - type_strict addition[:slug] => Symbol, addition[:url] => String, addition[:face] => String, addition[:range] => Range - links = select{|link| - (link[:range].to_a & addition[:range].to_a).empty? - } - links.push @@linkrule[addition[:slug]].merge(addition) - @generate_value = links.sort_by{ |r| r[:range].first }.freeze - Plugin.call(:message_modified, message) - end - - # _index_ 文字目のエンティティの要素を返す。エンティティでなければnilを返す - def segment_by_index(index) - segment_text.each{ |segment| - if segment.is_a? Integer - index -= segment - elsif segment.is_a? Hash - index -= segment[:face].size - end - if index < 0 - if segment.is_a? Hash - return segment - else - return nil end end } - nil end - - # "look http://example.com/" のようなツイートに対して、 - # ["l", "o", "o", "k", " ", {エンティティのURLの値}] - # のように、エンティティの情報を間に入れた配列にして返す。 - def segment_splitted - result = message.to_show.split(REGEXP_EACH_CHARACTER) - reverse_each{ |segment| - result[segment[:range]] = segment } - result.freeze end - - def segment_text - result = [] - segment_splitted.each{ |segment| - if segment.is_a? String - if result.last.is_a? Integer - result[-1] += 1 - else - result << 1 end - elsif segment.is_a? Hash - result << segment end } - result.freeze end - - def generate_value - @generate_value end - - def _generate_value - result = Set.new(message_entities) - @@linkrule.values.each{ |rule| - if rule[:regexp] - message.to_show.scan(rule[:regexp]){ - match = Regexp.last_match - pos = match.begin(0) - body = match.to_s.freeze - if not result.any?{ |this| this[:range].include?(pos) } - result << @@filter[rule[:slug]].call(rule.merge({ :message => message, - :range => Range.new(pos, pos + body.size, true), - :face => body, - :from => :_generate_value, - :url => body})).freeze end } end } - result.sort_by{ |r| r[:range].first }.freeze end - - - # Messageオブジェクトに含まれるentity情報を、 Message::Entity#to_a と同じ形式で返す。 - def message_entities - result = Set.new - if message[:entities] - message[:entities].each{ |slug, children| - children.each{ |link| - begin - rule = @@linkrule[slug] || {} - extended_entities = matched_extended_entites(slug, link[:display_url]) - if extended_entities.empty? - entity = @@filter[slug].call(rule.merge({ message: message, - from: :message_entities - }.merge(link))) - entity[:range] = get_range_by_face(entity) - result << entity.freeze - else - entities_from_extended_entities(link, extended_entities, message: message, slug: slug, rule: rule).each do |converted_entity| - converted_entity[:range] = get_range_by_face(converted_entity) - result << converted_entity.freeze end - end - rescue InvalidEntityError, RuntimeError => exception - error exception end } } - - end - result.sort_by{ |r| r[:range].first }.freeze end - - def get_range_by_face(link) - right = message.to_show.index(link[:url], link[:indices][0]) - left = message.to_show.rindex(link[:url], link[:indices][1]) - if right and left - start = [right - link[:indices][0], left - link[:indices][0]].map(&:abs).min + link[:indices][0] - start...(start + link[:url].size) - elsif right or left - start = right || left - start...(start + link[:url].size) - else - indices_to_range(link[:indices]) end end - - def indices_to_range(indices) - Range.new(self.class.index_to_escaped_index(message.to_show, indices[0]), - self.class.index_to_escaped_index(message.to_show, indices[1]), true) end - - # source_entity に対する extended_entity を、通常のエンティティに変換して返す。 - # source_entity のテキスト上の表現の1文字にextended_entityの1つを割り当て、最後のextended_entity - # には残りの全てを割り当てる。 - # ==== Args - # [source_entity] Hash 元となるエンティティ - # [extended_entities] Array Extended Entity - # ==== Return - # entityの配列 - def entities_from_extended_entities(source_entity, extended_entities, slug: source_entity[:slug], rule: @@linkrule[slug] || {}, message: nil) - type_strict source_entity => Hash, extended_entities => Array, slug => Symbol, rule => Hash - result = extended_entities.map.with_index do |extended_entity, index| - entity_rewrite = { - display_url: extended_entity[:media_url], - indices: [source_entity[:indices][0]+index, source_entity[:indices][0]+index+1] } - if 0 != index - entity_rewrite[:display_url] = "\n#{entity_rewrite[:display_url]}" end - @@filter[slug].call(rule.merge({ message: message, - from: :message_entities - }.merge(extended_entity). - merge(entity_rewrite))) end - result.last[:indices][1] = source_entity[:indices][1] - result end - - # slug と display_url が一致するextended entitiesを返す - # ==== Args - # [slug] Symbol Entityのスラッグ - # [display_url] String 探すExtended Entityのdisplay_url - # ==== Return - # Extended Entityの配列。見つからなかった場合は空の配列 - def matched_extended_entites(slug, display_url) - if defined?(message[:extended_entities][slug]) and message[:extended_entities][slug].is_a? Array - message[:extended_entities][slug].select do |link| - display_url == link[:display_url] end - else - [] end end - - - # to_showで得たエンティティデコードされた文字列の index が、 - # エンティティエンコードされた文字列ではどうなるかを返す。 - # ==== Args - # [decoded_string] String デコードされた文字列 - # [encoded_index] Fixnum エンコード済み文字列でのインデックス - # ==== Return - # Fixnum デコード済み文字列でのインデックス - def self.index_to_escaped_index(decoded_string, encoded_index) - decoded_string - .gsub(REGEXP_ENTITY_ENCODE_TARGET, &ESCAPE_RULE.method(:[])) - .slice(0, encoded_index) - .gsub(REGEXP_ENTITY_DECODE_TARGET, &UNESCAPE_RULE.method(:[])) - .size end - - class InvalidEntityError < Message::MessageError - end - - refresh - -end diff --git a/core/lib/mikutwitter/api_call_support.rb b/core/lib/mikutwitter/api_call_support.rb index 99a0d72c..a4952d1f 100644 --- a/core/lib/mikutwitter/api_call_support.rb +++ b/core/lib/mikutwitter/api_call_support.rb @@ -5,7 +5,7 @@ require "mikutwitter/query" require "json" require "timelimitedqueue" -miquire :core, "message", "user", "userlist" +miquire :core, "message", "user", "userlist", 'directmessage' module MikuTwitter::ApiCallSupport HTML_ATTR_UNESCAPE_HASH = { @@ -179,10 +179,11 @@ module MikuTwitter::ApiCallSupport def direct_message(dm) cnv = dm.dup - cnv[:sender] = user(dm[:sender]) + cnv[:user] = cnv[:sender] = user(dm[:sender]) cnv[:recipient] = user(dm[:recipient]) cnv[:exact] = true - cnv end + cnv[:created] = Time.parse(dm[:created_at]).localtime + Mikutter::Twitter::DirectMessage.new_ifnecessary(cnv) end def id(id) id end diff --git a/core/lib/mikutwitter/api_shortcuts.rb b/core/lib/mikutwitter/api_shortcuts.rb index f05c9eea..0495a9c1 100644 --- a/core/lib/mikutwitter/api_shortcuts.rb +++ b/core/lib/mikutwitter/api_shortcuts.rb @@ -222,7 +222,6 @@ module MikuTwitter::APIShortcuts stream_access_token = access_token("#{parsed_url.scheme}://#{parsed_url.host}") http = stream_access_token.consumer.http http.read_timeout = 90 - http.ssl_version = 'TLSv1' consumer = stream_access_token.consumer request = consumer.create_signed_request(:post, parsed_url.path, diff --git a/core/lib/retriever.rb b/core/lib/retriever.rb new file mode 100644 index 00000000..20da1288 --- /dev/null +++ b/core/lib/retriever.rb @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +module Retriever + # _model_slug_ をslugとして持つModelクラスを返す。 + # 見つからない場合、nilを返す。 + def self.Model(model_slug) + ObjectSpace.each_object(Retriever::Model.singleton_class) do |klass| + return klass if klass.slug == model_slug + end + nil + end +end + +require_relative 'retriever/cast' +require_relative 'retriever/datasource' +require_relative 'retriever/error' +require_relative 'retriever/model' +require_relative 'retriever/field_generator' +require_relative 'retriever/model/identity' +require_relative 'retriever/model/memory' +require_relative 'retriever/entity/blank_entity' +require_relative 'retriever/entity/regexp_entity' +require_relative 'retriever/entity/twitter_entity' +require_relative 'retriever/entity/url_entity' diff --git a/core/lib/retriever/cast.rb b/core/lib/retriever/cast.rb new file mode 100644 index 00000000..6fe9843d --- /dev/null +++ b/core/lib/retriever/cast.rb @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +module Retriever + @@cast = { + :int => lambda{ |v| begin v.to_i; rescue NoMethodError then raise InvalidTypeError end }, + :bool => lambda{ |v| !!(v and not v == 'false') }, + :string => lambda{ |v| begin v.to_s; rescue NoMethodError then raise InvalidTypeError end }, + :time => lambda{ |v| + if not v then + nil + elsif v.is_a? String then + Time.parse(v) + else + Time.at(v) + end + }, + :uri => lambda{ |v| + case v + when URI + v + when String + URI.parse(v) + else + raise InvalidTypeError + end + } + } + + def self.cast_func(type) + @@cast[type] + end + +end + diff --git a/core/lib/retriever/datasource.rb b/core/lib/retriever/datasource.rb new file mode 100644 index 00000000..633fac39 --- /dev/null +++ b/core/lib/retriever/datasource.rb @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +=begin rdoc +データの保存/復元を実際に担当するデータソース。 +データソースをモデルにModel::add_data_retrieverにて幾つでも参加させることが出来る。 +=end +module Retriever::DataSource + USE_ALL = -1 # findbyidの引数。全てのDataSourceから探索する + USE_LOCAL_ONLY = -2 # findbyidの引数。ローカルにあるデータのみを使う + + attr_accessor :keys + + # idをもつデータを返す。 + # もし返せない場合は、nilを返す + def findbyid(id, policy) + nil + end + + # 取得できたらそのRetrieverのインスタンスをキーにして実行されるDeferredを返す + def idof(id) + Thread.new{ findbyid(id) } end + alias [] idof + + # データの保存 + # データ一件保存する。保存に成功したか否かを返す。 + def store_datum(datum) + false + end + + def inspect + self.class.to_s + end +end diff --git a/core/lib/retriever/entity/blank_entity.rb b/core/lib/retriever/entity/blank_entity.rb new file mode 100644 index 00000000..1eb5ba0e --- /dev/null +++ b/core/lib/retriever/entity/blank_entity.rb @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +module Retriever::Entity + class BlankEntity + REGEXP_EACH_CHARACTER = //u.freeze + + include Enumerable + + attr_reader :message + + def initialize(message) + @message = message + @generate_value = [] end + + def each + to_a.each(&Proc.new) + end + + def reverse_each + to_a.reverse.each(&Proc.new) + end + + # [{range: リンクを貼る場所のRange, face: 表示文字列, url:リンク先}, ...] の配列を返す + # face: TLに印字される文字列。 + # url: 実際のリンク先。本当にURLになるかはリンクの種類に依存する。 + # 例えばハッシュタグ "#mikutter" の場合はこの内容は "mikutter" になる。 + def to_a + generate_value end + + # entityフィルタを適用した状態のMessageの本文を返す + def to_s + segment_splitted.map{ |s| + if s.is_a? Hash + s[:face] + else + s end }.join end + + # 外部からエンティティを書き換える。 + # これでエンティティが書き換えられた場合、イベントで書き換えが通知される。 + # また、エンティティの範囲が被った場合それを削除する + # ==== Args + # [addition] Hash 以下の要素を持つ配列 + # - :slug (required) :: Symbol エンティティの種類。:urls 等 + # - :url (required) :: String 実際のクエリ(リンク先URL等) + # - :face (required) :: String 表示する文字列 + # - :range (required) :: Range message上の置き換える範囲 + # - :message :: Message 親Message + # ==== Return + # self + def add(addition) + type_strict addition[:slug] => Symbol, addition[:url] => String, addition[:face] => String, addition[:range] => Range + links = select{|link| + (link[:range].to_a & addition[:range].to_a).empty? + } + links.push(addition) + @generate_value = links.sort_by{ |r| r[:range].first }.freeze + Plugin.call(:message_modified, message) + end + + # _index_ 文字目のエンティティの要素を返す。エンティティでなければnilを返す + def segment_by_index(index) + segment_text.each{ |segment| + if segment.is_a? Integer + index -= segment + elsif segment.is_a? Hash + index -= segment[:face].size + end + if index < 0 + if segment.is_a? Hash + return segment + else + return nil end end } + nil end + + # "look http://example.com/" のようなツイートに対して、 + # ["l", "o", "o", "k", " ", {エンティティのURLの値}] + # のように、エンティティの情報を間に入れた配列にして返す。 + def segment_splitted + result = message.to_show.split(REGEXP_EACH_CHARACTER) + reverse_each{ |segment| + result[segment[:range]] = segment } + result.freeze end + + def segment_text + result = [] + segment_splitted.each{ |segment| + if segment.is_a? String + if result.last.is_a? Integer + result[-1] += 1 + else + result << 1 end + elsif segment.is_a? Hash + result << segment end } + result.freeze end + + def generate_value + @generate_value end + + def get_range_by_face(link) + right = message.to_show.index(link[:url], link[:indices][0]) + left = message.to_show.rindex(link[:url], link[:indices][1]) + if right and left + start = [right - link[:indices][0], left - link[:indices][0]].map(&:abs).min + link[:indices][0] + start...(start + link[:url].size) + elsif right or left + start = right || left + start...(start + link[:url].size) + else + indices_to_range(link[:indices]) end end + + def indices_to_range(indices) + Range.new(self.class.index_to_escaped_index(message.to_show, indices[0]), + self.class.index_to_escaped_index(message.to_show, indices[1]), true) end + + end +end diff --git a/core/lib/retriever/entity/regexp_entity.rb b/core/lib/retriever/entity/regexp_entity.rb new file mode 100644 index 00000000..c7c8433d --- /dev/null +++ b/core/lib/retriever/entity/regexp_entity.rb @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +require_relative 'blank_entity' + +module Retriever::Entity + +=begin rdoc +特定の正規表現にマッチする部分を自動的にSegmentにするEntity。 + +==== Example + + class Sample < Retriever::Model + entity_class Retriever::Entity::RegexpEntity. + filter(/:(?:\w+):/, ->s{ s.merge(open: 'https://...') }). # :???: をクリックされたら対応する絵文字の画像(https://...)を開く + filter(/@(?:\w+)/, ->s{ s.merge(open: "https://twitter.com/#{s[:url]}") }) # @??? をクリックされたらTwitterでユーザページを開く + end + +=end + class RegexpEntity < BlankEntity + class << self + # 正規表現ルールを定義する + # ==== Args + # [regexp] Regexp Segmentを自動生成するための正規表現 + # [generator:] + # Proc テキストを装飾する範囲を表わすHashを引数として受け取って、それを加工して返すProc。 + # 引数はHashひとつだけで、加工する必要がなければ、引数の内容をそのまま返す。 + # ===== Argument of generator + # 引数 _generator:_ のProcに渡されるHashは、以下のキーを持つ + # [:message] Retriever::Model このEntityが装飾する本文を持っているModel + # [:range] Range 装飾する範囲(文字数)。 + # [:face] String _:message_ の本文中の _range_ の範囲にあるテキスト。これを書き換えると、実際の本文は変わらないが、表示上はこの内容に置き換わる。 + # [:url] String 歴史的経緯でこのような名前になっているがURLとは限らない。_:message_ の本文中の _range_ の範囲にあるテキスト。 _:face_ と違って、書き換わる前の内容が格納されている。 + # [:open] Retriever::Model|URI|nil デフォルトでは存在しない。内容を指定すると、UI上で本文の _:range_ の範囲がクリックされた時に、 :open イベントでそれを開くようになる。 + # [:callback] Proc|nil 利用は非推奨。できるだけ _:open_ を使う。デフォルトでは存在しない。内容を指定すると、UI上で本文の _:range_ の範囲がクリックされた時に、これが呼ばれるようになる。指定されている場合、 _:open_ より優先される。 + # ==== Return + # Class その正規表現を自動でリンクにする新しいEntityクラス + def filter(regexp, generator: ret_nth) + Class.new(self) do + @@autolink_condition = regexp.freeze + @@generator = generator + + def initialize(*rest) + super + segments = Set.new(@generate_value) + self.message.to_show.scan(@@autolink_condition) do + match = Regexp.last_match + pos = match.begin(0) + body = match.to_s.freeze + if not segments.any?{ |this| this[:range].include?(pos) } + segments << @@generator.( + message: message, + from: :regexp, + slug: :urls, + range: Range.new(pos, pos + body.size, true), + face: body, + url: body).freeze + end + end + @generate_value = segments.sort_by{ |r| r[:range].first }.freeze + end + end + end + end + + end +end diff --git a/core/lib/retriever/entity/segment.rb b/core/lib/retriever/entity/segment.rb new file mode 100644 index 00000000..42313676 --- /dev/null +++ b/core/lib/retriever/entity/segment.rb @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +# module Retriever::Entity +# Segment = Struct.new(:slug, :range, :face, :url, :open, :options) do +# def merge(args) +# new = self.dup +# args.each do |k, v| +# new[k] = v +# end +# new +# end +# end +# end diff --git a/core/lib/retriever/entity/twitter_entity.rb b/core/lib/retriever/entity/twitter_entity.rb new file mode 100644 index 00000000..904c6599 --- /dev/null +++ b/core/lib/retriever/entity/twitter_entity.rb @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +require_relative 'blank_entity' + +module Retriever::Entity + class TwitterEntity < BlankEntity + ESCAPE_RULE = {'&' => '&'.freeze ,'>' => '>'.freeze, '<' => '<'.freeze}.freeze + UNESCAPE_RULE = ESCAPE_RULE.invert.freeze + # REGEXP_EACH_CHARACTER = //u.freeze + REGEXP_ENTITY_ENCODE_TARGET = Regexp.union(*ESCAPE_RULE.keys.map(&Regexp.method(:escape))).freeze + REGEXP_ENTITY_DECODE_TARGET = Regexp.union(*ESCAPE_RULE.values.map(&Regexp.method(:escape))).freeze + + def initialize(*_rest) + super + set_message_entities + end + + private + def set_message_entities + @generate_value = message_entities + end + + def message_entities + result = Set.new + return result if not message[:entities] + message[:entities].each do |slug, twitter_entity_objects| + twitter_entity_objects.each do |link| + begin + rule = {} + extended_entities = matched_extended_entites(slug, link[:display_url]) + if extended_entities.empty? + result << normal_entity(slug, link).freeze + else + entities_from_extended_entities(link, extended_entities, message: message, slug: slug, rule: rule).each do |converted_entity| + converted_entity[:range] = get_range_by_face(converted_entity) + result << converted_entity.freeze end + end + rescue Retriever::InvalidEntityError, RuntimeError => exception + error exception end + end + end + result.to_a.compact.sort_by{ |r| r[:range].first }.freeze + end + + def normal_entity(slug, entity) + entity = { from: :twitter, + slug: slug + }.merge(entity) + case slug + when :urls + entity[:face] = (entity[:display_url] or entity[:url]) + entity[:url] = (entity[:expanded_url] or entity[:url]) + entity[:open] = entity[:url] + when :user_mentions + entity[:face] = entity[:url] = "@#{entity[:screen_name]}".freeze + user = Retriever::Model(:twitter_user) + if user + entity[:open] = user.findbyidname(entity[:screen_name], Retriever::DataSource::USE_LOCAL_ONLY) || + URI("https://twitter.com/#{entity[:screen_name]}") + else + entity[:open] = URI("https://twitter.com/#{entity[:screen_name]}") + end + when :hashtags + entity[:face] = entity[:url] = "##{entity[:text]}".freeze + twitter_search = Retriever::Model(:twitter_search) + if twitter_search + entity[:open] = twitter_search.new(query: entity[:text]) end + when :media + case entity[:video_info] and entity[:type] + when 'video' + variant = Array(entity[:video_info][:variants]) + .select{|v|v[:content_type] == "video/mp4"} + .sort_by{|v|v[:bitrate]} + .last + entity[:face] = "#{entity[:display_url]} (%.1fs)" % (entity[:video_info][:duration_millis]/1000.0) + entity[:open] = entity[:url] = variant[:url] + when 'animated_gif' + variant = Array(entity[:video_info][:variants]) + .select{|v|v[:content_type] == "video/mp4"} + .sort_by{|v|v[:bitrate]} + .last + entity[:face] = "#{entity[:display_url]} (GIF)" + entity[:open] = entity[:url] = variant[:url] + else + entity[:face] = entity[:display_url] + entity[:url] = entity[:media_url] + photo = Retriever::Model(:openimg_photo) + if photo + entity[:open] = photo.new(perma_link: entity[:media_url]) + else + entity[:open] = entity[:media_url] + end + end + else + error "Unknown entity slug `#{slug}' was detected." + return + end + entity.merge(range: get_range_by_face(entity)) + end + + # slug と display_url が一致するextended entitiesを返す + # ==== Args + # [slug] Symbol Entityのスラッグ + # [display_url] String 探すExtended Entityのdisplay_url + # ==== Return + # Extended Entityの配列。見つからなかった場合は空の配列 + def matched_extended_entites(slug, display_url) + if defined?(message[:extended_entities][slug]) and message[:extended_entities][slug].is_a? Array + message[:extended_entities][slug].select do |link| + display_url == link[:display_url] end + else + [] end end + + def get_range_by_face(link) + right = message.to_show.index(link[:url], link[:indices][0]) + left = message.to_show.rindex(link[:url], link[:indices][1]) + if right and left + start = [right - link[:indices][0], left - link[:indices][0]].map(&:abs).min + link[:indices][0] + start...(start + link[:url].size) + elsif right or left + start = right || left + start...(start + link[:url].size) + else + indices_to_range(link[:indices]) end end + + def indices_to_range(indices) + Range.new(self.class.index_to_escaped_index(message.to_show, indices[0]), + self.class.index_to_escaped_index(message.to_show, indices[1]), true) end + + # source_entity に対する extended_entity を、通常のエンティティに変換して返す。 + # source_entity のテキスト上の表現の1文字にextended_entityの1つを割り当て、最後のextended_entity + # には残りの全てを割り当てる。 + # ==== Args + # [source_entity] Hash 元となるエンティティ + # [extended_entities] Array Extended Entity + # ==== Return + # entityの配列 + def entities_from_extended_entities(source_entity, extended_entities, slug: source_entity[:slug], rule:, message: nil) + type_strict source_entity => Hash, extended_entities => Array, slug => Symbol + result = extended_entities.map.with_index do |extended_entity, index| + entity_rewrite = { + display_url: extended_entity[:media_url], + indices: [source_entity[:indices][0]+index, source_entity[:indices][0]+index+1] } + if 0 != index + entity_rewrite[:display_url] = "\n#{entity_rewrite[:display_url]}" end + normal_entity(slug, extended_entity.merge(entity_rewrite)) end + result.last[:indices][1] = source_entity[:indices][1] + result end + + # to_showで得たエンティティデコードされた文字列の index が、 + # エンティティエンコードされた文字列ではどうなるかを返す。 + # ==== Args + # [decoded_string] String デコードされた文字列 + # [encoded_index] Fixnum エンコード済み文字列でのインデックス + # ==== Return + # Fixnum デコード済み文字列でのインデックス + def self.index_to_escaped_index(decoded_string, encoded_index) + decoded_string + .gsub(REGEXP_ENTITY_ENCODE_TARGET, &ESCAPE_RULE.method(:[])) + .slice(0, encoded_index) + .gsub(REGEXP_ENTITY_DECODE_TARGET, &UNESCAPE_RULE.method(:[])) + .size end + + end +end diff --git a/core/lib/retriever/entity/url_entity.rb b/core/lib/retriever/entity/url_entity.rb new file mode 100644 index 00000000..c24e7a68 --- /dev/null +++ b/core/lib/retriever/entity/url_entity.rb @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +require_relative 'regexp_entity' + +module Retriever::Entity + +=begin rdoc +schemeはhttpまたはhttpsのURLを全てリンクにするEntity。 +==== Examples + + Retriever::Entity::URLEntity.new(message) + +=end + URLEntity = Retriever::Entity::RegexpEntity.filter(URI.regexp(%w<http https>), + generator: ->s{ s.merge(open: s[:url]) }) +end diff --git a/core/lib/retriever/error.rb b/core/lib/retriever/error.rb new file mode 100644 index 00000000..a5a6e943 --- /dev/null +++ b/core/lib/retriever/error.rb @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +module Retriever + class RetrieverError < StandardError; end + + class InvalidTypeError < RetrieverError; end + + class InvalidEntityError < RetrieverError; end + + # 実装してもしなくてもいいメソッドが実装されておらず、結果を得られない + class NotImplementedError < RetrieverError; end + + # IDやURIなどの一意にリソースを特定する情報を使ってデータソースに問い合わせたが、 + # 対応する情報が見つからず、Modelを作成できない + class ModelNotFoundError < RetrieverError; end +end diff --git a/core/lib/retriever/field_generator.rb b/core/lib/retriever/field_generator.rb new file mode 100644 index 00000000..134f6218 --- /dev/null +++ b/core/lib/retriever/field_generator.rb @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +class Retriever::FieldGenerator + def initialize(model_klass) + @model_klass = model_klass + end + + def int(field_name, required: false) + @model_klass.add_field(field_name, type: :int, required: required) + end + + def string(field_name, required: false) + @model_klass.add_field(field_name, type: :string, required: required) + end + + def bool(field_name, required: false) + @model_klass.add_field(field_name, type: :bool, required: required) + end + + def time(field_name, required: false) + @model_klass.add_field(field_name, type: :time, required: required) + end + + def uri(field_name, required: false) + @model_klass.add_field(field_name, type: :uri, required: required) + end + + def has(field_name, type, required: false) + @model_klass.add_field(field_name, type: type, required: required) + end +end + + + + + + + + diff --git a/core/lib/retriever/mixin/message_mixin.rb b/core/lib/retriever/mixin/message_mixin.rb new file mode 100644 index 00000000..888425dd --- /dev/null +++ b/core/lib/retriever/mixin/message_mixin.rb @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- + +=begin rdoc +Model用のmoduleで、これをincludeするとMessageに必要最低限のメソッドがロードされ、タイムラインなどに表示できるようになる。 +=end +module Retriever::Model::MessageMixin + # この投稿がMentionで、自分が誰かに宛てたものであれば真 + # ==== Return + # [true] 自分のMention + # [false] 上記以外 + def mentioned_by_me? + false + end + + # この投稿を、現在の _Service.primary_ でお気に入りとしてマークする。 + # ==== Args + # [_fav] bool お気に入り状態。真ならお気に入りにし、偽なら外す + # ==== Return + # [Deferred] 成否判定 + def favorite(_fav=true) + Deferred.new{ true } + end + + # この投稿が、 _Service.primary_ にお気に入り登録されているか否かを返す。 + # ==== Return + # [true] お気に入りに登録している + # [false] 登録していない + def favorite? + false + end + + # このRetrieverをお気に入りに登録している _Retriever::Model_ を列挙する。 + # ==== Return + # [Enumerable<Retriever::Model>] お気に入りに登録しているオブジェクト + def favorited_by + [] + end + + # この投稿が、 _Service.primary_ でお気に入りの対応状況を取得する。 + # 既にお気に入りに追加されているとしても、Serviceが対応しているならtrueとなる。 + # ==== Return + # [true] お気に入りに対応している + # [false] 対応していない + def favoritable? + false + end + + # このRetrieverをReTweetする。 + # ReTweetとは、Retriever自体を添付した、内容が空のRetrieverを作成すること。 + # 基本的にはTwitter用で、他の用途は想定していない。 + # ==== Return + # [Deferred] 成否判定 + def retweet + Deferred.new{ true } + end + + # このインスタンスがReTweetの基準を満たしているか否かを返す。 + # ==== Return + # [true] このインスタンスはReTweetである + # [false] ReTweetではない + def retweet? + false + end + + # _Service.primary_ で、このインスタンスがReTweetされているか否かを返す + # ==== Return + # [true] 既にReTweetしている + # [false] していない + def retweeted? + false + end + + # このインスタンスのReTweetにあたる _Retriever::Model_ を列挙する。 + # ==== Return + # [Enumerable<Retriever::Model>] このインスタンスのReTweetにあたるインスタンス + def retweeted_by + [] + end + + # _Service.primary_ が、このインスタンスをReTweetすることに対応しているか否かを返す + # 既にReTweetしている場合は、必ず _true_ を返す。 + # ==== Return + # [true] ReTweetに対応している + # [false] していない + def retweetable? + false + end + + # このMessageがリツイートなら、何のリツイートであるかを返す。 + # 返される値の retweet? は常に false になる + # ==== Args + # [force_retrieve] 真なら、ツイートがメモリ上に見つからなかった場合Twitter APIリクエストを発行する + # ==== Return + # [Retriever::Model] ReTweet元のMessage + # [nil] ReTweetではない + def retweet_source(force_retrieve=nil) + nil + end + + def quoting? + false + end + + def has_receive_message? + false + end + + def to_show + @to_show ||= self[:description].gsub(/&(gt|lt|quot|amp);/){|m| {'gt' => '>', 'lt' => '<', 'quot' => '"', 'amp' => '&'}[$1] }.freeze + end + + def to_message + self + end + alias :message :to_message + + def system? + false + end + + def modified + created + end + + def from_me? + false + end + + def to_me? + false + end + + def idname + user.idname + end + + def repliable? + false + end + + def perma_link + nil + end + + def receive_user_screen_names + [] + end +end diff --git a/core/lib/retriever/mixin/user_mixin.rb b/core/lib/retriever/mixin/user_mixin.rb new file mode 100644 index 00000000..d7889fbe --- /dev/null +++ b/core/lib/retriever/mixin/user_mixin.rb @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + +=begin rdoc +Model用のmoduleで、これをincludeするとUserに要求されるいくつかのメソッドが定義される。 +=end +module Retriever::Model::UserMixin + def user + self + end + + def profile_image_url_large + profile_image_url + end + + def verified? + false + end + + def protected? + false + end +end diff --git a/core/lib/retriever/model.rb b/core/lib/retriever/model.rb new file mode 100644 index 00000000..7fbf1d09 --- /dev/null +++ b/core/lib/retriever/model.rb @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +=begin rdoc + いろんなリソースの基底クラス +=end + +miquire :lib, 'typed-array' + +class Retriever::Model + include Comparable + + class << self + extend Gem::Deprecate + + attr_reader :slug + + # 新しいオブジェクトを生成します + # 既にそのカラムのインスタンスが存在すればそちらを返します + # また、引数のハッシュ値はmergeされます。 + def generate(args) + return args if args.is_a?(self) + self.new(args) + end + + def rewind(args) + type_strict args => Hash + result_strict(:merge){ new_ifnecessary(args) }.merge(args) + end + + # まだそのレコードのインスタンスがない場合、それを生成して返します。 + def new_ifnecessary(hash) + type_strict hash => tcor(self, Hash) + result_strict(self) do + case hash + when self + hash + when Hash + self.new(hash) + else + raise ArgumentError.new("incorrect type #{hash.class} #{hash.inspect}") end end end + + # Modelのインスタンスのuriスキーム。オーバライドして適切な値にする + # ==== Return + # [String] URIスキーム + memoize def scheme + self.to_s.split('::',2).first.gsub(/\W/,'').downcase.freeze + end + + # Modelのインスタンスのホスト名。オーバライドして適切な値にする + # ==== Return + # [String] ホスト名 + memoize def host + self.to_s.split('::',2).last.split('::').reverse.join('.').gsub(/[^\w\.]/,'').downcase.freeze + end + + # Modelにフィールド _field_name_ を追加する。 + # ==== Args + # [field_name] Symbol フィールドの名前 + # [type] Symbol フィールドのタイプ。:int, :string, :bool, :time のほか、Retriever::Modelのサブクラスを指定する + # [required] boolean _true_ なら、この項目を必須とする + def add_field(field_name, type:, required: false) + (@keys ||= []) << [field_name, type, required] + if type.is_a? Symbol + define_method(field_name) do + @value[field_name] + end + else + define_method(field_name) do + if @value[field_name].is_a? Retriever::Model + @value[field_name] + end + end + + define_method("#{field_name}!") do + mainthread_only + if @value[field_name].is_a? Retriever::Model + @value[field_name] + else + type.findbyid(@value[field_name], Retriever::DataSource::USE_ALL) + end + end + end + + define_method("#{field_name}?") do + !!@value[field_name] + end + + define_method("#{field_name}=") do |value| + @value[field_name] = value + self.class.store_datum(self) + value + end + self + end + + def keys + @keys end + + # Entityクラスを設定する。 + # ==== Args + # [klass] Class 新しく設定するEntityクラス + # ==== Return + # [Class] セットされた(されている)Entityクラス + def entity_class(klass=nil) + if klass + @entity_class = klass + else + @entity_class ||= Retriever::Entity::BlankEntity + end + end + + # srcが正常にModel化できるかどうかを返します。 + def valid?(src) + return src.is_a?(self) if not src.is_a?(Hash) + not self.get_error(src) end + + # srcがModel化できない理由を返します。 + def get_error(src) + self.keys.each{ |column| + key, type, required = *column + begin + Retriever::Model.cast(src[key], type, required) + rescue Retriever::InvalidTypeError=>e + return e.to_s + "\nin key '#{key}' value '#{src[key]}'" end } + false end + + # + # プライベートクラスメソッド + # + + # Modelの情報を設定する。 + # このメソッドを呼ぶと、他のプラグインがこのRetrieverを見つけることができるようになるので、 + # 抽出タブの抽出条件が追加されたり、設定で背景色が指定できるようになる + # ==== Args + # [new_slug] Symbol + # [name:] String Modelの名前 + # [reply:] bool このRetrieverに、宛先が存在するなら真 + # [myself:] bool このRetrieverを、自分のアカウントによって作成できるなら真 + def register(new_slug, + name: new_slug.to_s, + reply: true, + myself: true + ) + @slug = new_slug + @name = name.freeze + retriever_spec = {slug: @slug, + name: @name, + reply: reply, + myself: myself + }.freeze + plugin do + filter_retrievers do |retrievers| + retrievers << retriever_spec + [retrievers] + end + end + end + + def field + Retriever::FieldGenerator.new(self) + end + + # あるURIが、このModelを示すものであれば真を返す条件 _condition_ を設定する。 + # _condition_ === uri が実行され、真を返せばそのURIをこのModelで取り扱えるということになる + # ==== Args + # [condition] 正規表現など、URIにマッチするもの + # ==== Return + # self + # ==== Block + # 実際にURIが指し示すリソースの内容を含んだModelを作って返す + # ===== Args + # [uri] URI マッチしたURI + # ===== Return + # [Delayer::Deferred::Deferredable] + # ネットワークアクセスを行って取得するなど取得に時間がかかる場合 + # [self] + # すぐにModelを生成できる場合、そのModel + # ===== Raise + # [Retriever::ModelNotFoundError] _uri_ に対応するリソースが見つからなかった + def handle(condition) # :yield: uri + model_slug = self.slug + plugin do + if condition.is_a? Regexp + filter_model_of_uri do |uri, models| + if condition =~ uri.to_s + models << model_slug + end + [uri, models] + end + else + filter_model_of_uri do |uri, models| + if condition === uri + models << model_slug + end + [uri, models] + end + end + end + if block_given? + class << self + define_method(:find_by_uri, Proc.new) + end + end + end + + # URIに対応するリソースの内容を持ったModelを作成する。 + # URIに対応する情報はネットワーク上などから取得される場合もある。そういった場合はこのメソッドは + # Delayer::Deferred::Deferredable を返す可能性がある。 + # このメソッドの振る舞いを変更したい場合は、 _handle_ メソッドを利用する。 + # ==== Args + # [uri] _handle_ メソッドで指定したいずれかの条件に一致するURI + # ==== Return + # [Delayer::Deferred::Deferredable] + # ネットワークアクセスを行って取得するなど取得に時間がかかる場合 + # [self] + # すぐにModelを生成できる場合、そのModel + # ==== Raise + # [Retriever::NotImplementedError] _handle_ メソッドを一度もブロック付きで呼び出しておらず、Modelを取得できない + # [Retriever::ModelNotFoundError] _uri_ に対応するリソースが見つからなかった + def find_by_uri(uri) + raise Retriever::NotImplementedError, "#{self}.find_by_uri does not implement." + end + + def plugin + if not @slug + raise Retriever::RetrieverError, "`#{self}'.slug is not set." + end + if block_given? + Plugin.create(:"retriever_model_#{@slug}", &Proc.new) + else + Plugin.create(:"retriever_model_#{@slug}") + end + end + + # Modelが生成・更新された時に呼ばれるコールバックメソッドです + def store_datum(retriever); end + + # 値を、そのカラムの型にキャストします。 + # キャスト出来ない場合はInvalidTypeError例外を投げます + def cast(value, type, required=false) + if value.nil? + raise Retriever::InvalidTypeError, 'it is required value'+[value, type, required].inspect if required + nil + elsif type.is_a?(Symbol) + begin + result = (value and Retriever::cast_func(type).call(value)) + if required and not result + raise Retriever::InvalidTypeError, 'it is required value, but returned nil from cast function' end + result + rescue Retriever::InvalidTypeError + raise Retriever::InvalidTypeError, "#{value.inspect} is not #{type}" end + elsif type.is_a?(Array) + if value.respond_to?(:map) + value.map{|v| cast(v, type.first, required)} + elsif not value + nil + else + raise Retriever::InvalidTypeError, 'invalid type' end + elsif value.is_a?(type) + value + elsif self.cast(value, type.keys.assoc(:id)[1], true) + value end end + + memoize def container_class + TypedArray(Retriever::Model) end + end + + def initialize(args) + type_strict args => Hash + @value = args.dup + validate + self.class.store_datum(self) + end + + # Entityのリストを返す。 + # ==== Return + # Retriever::Entity::BlankEntity + def links + @entity ||= self.class.entity_class.new(self) + end + alias :entity :links + + # データをマージする。 + # selfにあってotherにもあるカラムはotherの内容で上書きされる。 + # 上書き後、データはDataSourceに保存される + def merge(other) + @value.update(other.to_hash) + validate + self.class.store_datum(self) + end + + # このModelのパーマリンクを返す。 + # パーマリンクはWebのURLで、Web上のリソースでない場合はnilを返す。 + # ==== Return + # 次のいずれか + # [URI::HTTP] パーマリンク + # [nil] パーマリンクが存在しない + def perma_link + nil + end + + # このModelのURIを返す。 + # ==== Return + # [URI::Generic] パーマリンク + def uri + perma_link || URI::Generic.new(self.class.scheme,nil,self.class.host,nil,nil,path,nil,nil,nil) + end + + # このRetrieverが、登録されているアカウントのうちいずれかが作成したものであれば true を返す + # ==== Args + # [service] Service | Enumerable 「自分」のService + # ==== Return + # [true] 自分のによって作られたオブジェクトである + # [false] 自分のによって作られたオブジェクトではない + def me?(service=nil) + false end + + memoize def hash + self.uri.to_s.hash ^ self.class.hash end + + def <=>(other) + if other.is_a?(Retriever::Model) + created - other.created + elsif other.respond_to?(:[]) and other[:created] + created - other[:created] + else + id - other end end + + def ==(other) + if other.is_a? Retriever::Model + self.class == other.class && uri == other.uri + end + end + + def eql?(other) + self == other + end + + def to_hash + @value.dup + end + + # カラムの生の内容を返す + def fetch(key) + @value[key.to_sym] end + alias [] fetch + + # 速い順にcount個のRetrieverだけに問い合わせて返す + def get(key, count=1) + result = @value[key.to_sym] + column = self.class.keys.assoc(key.to_sym) + if column and result + type = column[1] + if type.is_a? Symbol + Retriever::cast_func(type).call(result) + elsif not result.is_a?(Retriever::Model::Identity) + result = type.findbyid(result, count) + if result + return @value[key.to_sym] = result end end end + result end + + + # カラムに別の値を格納する。 + # 格納後、データはDataSourceに保存される + def []=(key, value) + @value[key.to_sym] = value + self.class.store_datum(self) + value end + + # カラムと型が違うものがある場合、例外を発生させる。 + def validate + raise RuntimeError, "argument is #{@value}, not Hash" if not @value.is_a?(Hash) + self.class.keys.each{ |column| + key, type, required = *column + begin + Retriever::Model.cast(self.fetch(key), type, required) + rescue Retriever::InvalidTypeError=>e + estr = e.to_s + "\nin #{self.fetch(key).inspect} of #{key}" + warn estr + warn @value.inspect + raise Retriever::InvalidTypeError, estr end } end + + # キーとして定義されていない値を全て除外した配列を生成して返す。 + # また、Modelを子に含んでいる場合、それを外部キーに変換する。 + def filtering + datum = self.to_hash + result = Hash.new + self.class.keys.each{ |column| + key, type = *column + begin + result[key] = Retriever::Model.cast(datum[key], type) + rescue Retriever::InvalidTypeError=>e + raise Retriever::InvalidTypeError, e.to_s + "\nin #{datum.inspect} of #{key}" end } + result end + + private + # URIがデフォルトで使うpath要素 + def path + @path ||= "/#{SecureRandom.uuid}" + end + +end + diff --git a/core/lib/retriever/model/identity.rb b/core/lib/retriever/model/identity.rb new file mode 100644 index 00000000..ead0e8d3 --- /dev/null +++ b/core/lib/retriever/model/identity.rb @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +=begin rdoc +Retrieverにこのmixinをincludeすると、findbyid()によってそのIDをもつインスタンスを得ることができる。 +利用するclassは、idメソッドを実装している必要がある。 +=end +module Retriever::Model::Identity + module IdentityExtend + # データソースを返す。 + # findbyidは、このデータソースに対して行われる + def memory + @memory ||= Retriever::Model::Memory.new(self) end + + # idキーが _id_ のインスタンスを返す。 + # ==== Args + # [id] Integer|Enumerable 検索するIDか、IDを列挙するEnumerable + # ==== Return + # 次のいずれか + # [nil] その条件で見つけられなかった場合 + # [Retriever] 見つかった場合 + # [Enumerable] _id_ にEnumerableを渡した場合。列挙される順番は、 _id_ の順番どおり。 + def findbyid(id, policy=Retriever::DataSource::USE_ALL) + memory.findbyid(id, policy) end + + # :nodoc: + def generate(args, policy=Retriever::DataSource::USE_ALL) + return self.findbyid(args, policy) if not(args.is_a? Hash) + result = self.findbyid(args[:id], policy) + return result.merge(args) if result + super(args) + end + + # :nodoc: + def new_ifnecessary(hash) + type_strict hash => tcor(self, Hash) + result_strict(self) do + if hash.is_a?(self) + hash + elsif hash[:id] and hash[:id] != 0 + memory.findbyid(hash[:id].to_i, Retriever::DataSource::USE_LOCAL_ONLY) or super + else + super end end end + + # :nodoc: + def store_datum(datum) + memory.store_datum(datum) end + end + + def self.included(klass) + klass.extend(IdentityExtend) + end + + memoize def hash + self.id.hash ^ self.class.hash end +end diff --git a/core/lib/retriever/model/memory.rb b/core/lib/retriever/model/memory.rb new file mode 100644 index 00000000..299e44bc --- /dev/null +++ b/core/lib/retriever/model/memory.rb @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +class Retriever::Model::Memory + include Retriever::DataSource + + def initialize(klass=Retriever::Model) + @storage = WeakStorage.new(Integer, klass) end + + def findbyid(id, policy) + if id.is_a? Enumerable + id.map{ |i| @storage[i.to_i] } + else + @storage[id.to_i] end + end + + def store_datum(datum) + @storage[datum.id] = datum + end +end diff --git a/core/message.rb b/core/message.rb index 2c92ef67..49bec4d7 100644 --- a/core/message.rb +++ b/core/message.rb @@ -25,8 +25,10 @@ class Message < Retriever::Model ).freeze extend Gem::Deprecate + include Retriever::Model::Identity + + register :twitter_tweet, name: "Tweet" - @@system_id = 0 @@appear_queue = TimeLimitedQueue.new(65536, 0.1, Set){ |messages| Plugin.call(:appear, messages) } @@ -44,18 +46,37 @@ class Message < Retriever::Model # post | post object(Service) # image | image(URL or Image object) - self.keys = [[:id, :int, true], # ID - [:message, :string, true], # Message description - [:user, User, true], # Send by user - [:receiver, User], # Send to user - [:replyto, Message], # Reply to this message - [:retweet, Message], # ReTweet to this message - [:source, :string], # using client - [:geo, :string], # geotag - [:exact, :bool], # true if complete data - [:created, :time], # posted time - [:modified, :time], # updated time - ] + field.int :id, required: true + field.string :message, required: true # Message description + field.has :user, User, required: true # Send by user + field.has :receiver, User # Send to user + field.has :replyto, Message # Reply to this message + field.has :retweet, Message # ReTweet to this message + field.string :source # using client + field.string :geo # geotag + field.bool :exact # true if complete data + field.time :created # posted time + field.time :modified # updated time + + entity_class Retriever::Entity::TwitterEntity + + handle PermalinkMatcher do |uri| + match = PermalinkMatcher.match(uri.to_s) + notice match.inspect + if match + message = findbyid(match[:id].to_i, Retriever::DataSource::USE_LOCAL_ONLY) + notice message.inspect + if message + message + else + Thread.new do + findbyid(match[:id].to_i, Retriever::DataSource::USE_ALL) + end + end + else + raise Retriever::RetrieverError, "id##{match[:id]} does not exist in #{self}." + end + end def self.container_class Messages end @@ -65,11 +86,13 @@ class Message < Retriever::Model @@appear_queue.push(message) end + def self.memory + @memory ||= DataSource.new end + # Message.newで新しいインスタンスを作らないこと。インスタンスはコアが必要に応じて作る。 # 検索などをしたい場合は、 _Retriever_ のメソッドを使うこと def initialize(value) type_strict value => Hash - value.update(system) if value[:system] if not(value[:image].is_a?(Message::Image)) and value[:image] value[:image] = Message::Image.new(value[:image]) end super(value) @@ -77,13 +100,12 @@ class Message < Retriever::Model self[:replyto].add_child(self) end if self[:retweet].is_a? Message self[:retweet].add_child(self) end - @entity = Entity.new(self) Message.appear(self) end # 投稿主のidnameを返す def idname - user[:idname] + user.idname end # この投稿へのリプライをつぶやく @@ -124,7 +146,7 @@ class Message < Retriever::Model # 投稿がシステムメッセージだった場合にtrueを返す def system? - self[:system] + false end # この投稿にリプライする権限があればtrueを返す @@ -133,12 +155,12 @@ class Message < Retriever::Model # この投稿をお気に入りに追加する権限があればtrueを返す def favoritable? - Service.primary and not(system?) end + Service.primary end alias favoriable? favoritable? # この投稿をリツイートする権限があればtrueを返す def retweetable? - Service.primary and not system? and not protected? end + Service.primary and not protected? end # この投稿を削除する権限があればtrueを返す def deletable? @@ -146,12 +168,11 @@ class Message < Retriever::Model # この投稿の投稿主のアカウントの全権限を所有していればtrueを返す def from_me?(services=Service) - return false if system? services.map(&:user_obj).include?(self[:user]) end # この投稿が自分宛ならばtrueを返す def to_me?(services=Service) - system? or services.map(&:user_obj).find(&method(:receive_to?)) end + services.map(&:user_obj).find(&method(:receive_to?)) end # この投稿が公開されているものならtrueを返す。少しでも公開範囲を限定しているならfalseを返す。 def protected? @@ -164,9 +185,10 @@ class Message < Retriever::Model def verified? user.verified? end - # この投稿の投稿主を返す + # この投稿の投稿主を返す。messageについては、userが必ず付与されていることが保証されているので + # Deferredを返さない def user - self.get(:user, -1) end + self[:user] end def service warn "Message#service is obsolete method. use `Service.primary'." @@ -325,30 +347,30 @@ class Message < Retriever::Model def quoting? !!quoting_ids.first end - # selfを引用しているツイート _message_ を登録する + # selfを引用している _retriever_ を登録する # ==== Args - # [message] Message selfを引用しているMessage + # [retriever] Retriever::Model selfを引用しているRetriever # ==== Return # self - def add_quoted_by(message) + def add_quoted_by(retriever) atomic do - @quoted_by ||= Messages.new - unless @quoted_by.include? message + @quoted_by ||= Retriever::Model.container_class.new + unless @quoted_by.include? retriever if @quoted_by.frozen? - @quoted_by = Messages.new(@quoted_by + [message]) + @quoted_by = Retriever::Model.container_class.new(@quoted_by + [retriever]) else - @quoted_by << message end end + @quoted_by << retriever end end self end end - # selfを引用しているツイートを返す + # selfを引用しているRetrieverを返す # ==== Return - # Messages selfを引用しているMessageの配列 + # Retriever::Model.container_class selfを引用しているRetriever::Modelの配列 def quoted_by if defined? @quoted_by @quoted_by else atomic do - @quoted_by ||= Messages.new end end.freeze end + @quoted_by ||= Retriever::Model.container_class.new end end.freeze end # self が、何らかのツイートから引用されているなら真を返す # ==== Return @@ -478,7 +500,7 @@ class Message < Retriever::Model # ==== Return # このMessageの子全てをSetにまとめたもの def children_all - children.inject(Messages.new([self])){ |result, item| result.concat item.children_all } end + children.inject(Retriever::Model.container_class.new([self])){ |result, item| result.concat item.children_all } end # この投稿をお気に入りに登録したUserをSetオブジェクトにまとめて返す。 def favorited_by @@ -544,11 +566,6 @@ class Message < Retriever::Model self[:message].to_s.freeze end - # リンクを貼る場所とその種類を表現するEntityオブジェクトを返す - def links - @entity end - alias :entity :links - def inspect @value.inspect end @@ -578,10 +595,11 @@ class Message < Retriever::Model # このMessageのパーマリンクを取得する # ==== Return - # パーマリンクのURL(String)か、存在しない場合はnil + # 次のいずれか + # [URI] パーマリンク + # [nil] パーマリンクが存在しない def perma_link - if not system? - "https://twitter.com/#{user[:idname]}/status/#{self[:id]}".freeze end end + URI.parse("https://twitter.com/#{user[:idname]}/status/#{self[:id]}").freeze end memoize :perma_link alias :parma_link :perma_link deprecate :parma_link, "perma_link", 2016, 12 @@ -639,12 +657,6 @@ class Message < Retriever::Model retweeted_sources add_retweet_in_this_thread(retweet_user, created_at) } end end - # このMessageがサービスに投稿された時刻を返す - # ==== Return - # Time 投稿時刻 - def created - self[:created] end - # 最終更新日時を取得する def modified @value[:modified] ||= [created, *(@retweets || []).map{ |x| x.modified }].compact.max @@ -678,10 +690,27 @@ class Message < Retriever::Model Plugin::call(:message_modified, self) end self end - def system - { :id => @@system_id += 1, - :user => User.system, - :created => Time.now } end + class DataSource < Retriever::Model::Memory + def findbyid(id, policy) + if id.is_a? Enumerable + super.map do |v| + case v + when Message + v + else + findbyid(v) end end + else + result = super + if result + result + elsif policy == Retriever::DataSource::USE_ALL + result = Service.primary.scan(:status_show, id: id) + result end end + rescue Exception => err + error err + raise err + end + end # # Sub classes @@ -747,5 +776,3 @@ end class Messages < TypedArray(Message) end - -miquire :core, 'entity' diff --git a/core/mui/cairo_cell_renderer_message.rb b/core/mui/cairo_cell_renderer_message.rb index 8563c52a..4ecfb9d4 100644 --- a/core/mui/cairo_cell_renderer_message.rb +++ b/core/mui/cairo_cell_renderer_message.rb @@ -7,13 +7,12 @@ require 'gtk2' module Gtk class CellRendererMessage < CellRendererPixbuf type_register - install_property(GLib::Param::String.new("message_id", "message_id", "showing message", "hoge", GLib::Param::READABLE|GLib::Param::WRITABLE)) + install_property(GLib::Param::String.new("uri", "uri", "Resource URI", "hoge", GLib::Param::READABLE|GLib::Param::WRITABLE)) - attr_reader :message_id, :message + attr_reader :message def initialize() - super() - @message = nil end + super() end # Register events for this Renderer: signal_new("button_press_event", GLib::Signal::RUN_FIRST, nil, nil, @@ -73,9 +72,9 @@ module Gtk motioned = [path, column, cell_x, cell_y] signal_emit("motion_notify_event", e, *motioned) if last_motioned - motioned_id = @tree.get_record(motioned[0]).id rescue nil - last_motioned_id = @tree.get_record(last_motioned[0]).id rescue nil - if(last_motioned_id and motioned_id != last_motioned_id) + motioned_message = @tree.get_record(motioned[0]).message rescue nil + last_motioned_message = @tree.get_record(last_motioned[0]).message rescue nil + if(last_motioned_message and motioned_message != last_motioned_message) emit_leave_notify_from_event_motion(e, *last_motioned) end end last_motioned = motioned end } @@ -123,13 +122,14 @@ module Gtk Gdk::MiraclePainter.new(message, avail_width).set_tree(@tree) end - def message_id=(id) - if id && id.to_i > 0 - message = Message.findbyid(id.to_i, -2) - if message - return render_message(message) end end - self.pixbuf = GdkPixbuf::Pixbuf.new(file: Skin.get('notfound.png')) + def uri=(uri) + record = @tree.get_record_by_uri(uri) + if record and record.message + return render_message(record.message) + else + self.pixbuf = GdkPixbuf::Pixbuf.new(file: Skin.get('notfound.png')) end rescue Exception => e + error e if Mopt.debug raise e end self.pixbuf = GdkPixbuf::Pixbuf.new(file: Skin.get('notfound.png')) end diff --git a/core/mui/cairo_inner_tl.rb b/core/mui/cairo_inner_tl.rb index ccc67f5e..f3f8beb7 100644 --- a/core/mui/cairo_inner_tl.rb +++ b/core/mui/cairo_inner_tl.rb @@ -15,9 +15,9 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD type_register('GtkInnerTL') # TLの値を返すときに使う - Record = Struct.new(:id, :message, :order, :miracle_painter) + Record = Struct.new(:uri, :message, :order, :miracle_painter) - MESSAGE_ID = 0 + URI = 0 MESSAGE = 1 ORDER = 2 MIRACLE_PAINTER = 3 @@ -33,7 +33,7 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD super() @force_retrieve_in_reply_to = :auto @@current_tl ||= self - @id_dict = {} # message_id: iter + @iter_dict = {} # String: Gdk::TreeIter @order = ->(m) { m.modified.to_i } self.name = 'timeline' set_headers_visible(false) @@ -45,7 +45,7 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD set_events end def cell_renderer_message - @cell_renderer_message ||= Gtk::CellRendererMessage.new() + @cell_renderer_message ||= Gtk::CellRendererMessage.new end def column_schemer @@ -53,7 +53,7 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD cell_renderer_message.tree = self cell_renderer_message }, - :kind => :message_id, :widget => :text, :type => String, :label => ''}, + :kind => :uri, :widget => :text, :type => String, :label => ''}, {:kind => :text, :widget => :text, :type => Message}, {:kind => :text, :type => Integer}, {:kind => :text, :type => Object} @@ -77,23 +77,39 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD def handle_row_activated end + def create_postbox(options) + options = options.dup + options[:before_post_hook] = ->(this) { + get_ancestor(Gtk::Window).set_focus(self) unless self.destroyed? } + pb = Gtk::PostBox.new(options).show_all + postbox.closeup(pb) + pb.on_delete(&Proc.new) if block_given? + get_ancestor(Gtk::Window).set_focus(pb.post) + pb end + private :create_postbox + def reply(options = {}) ctl = Gtk::TimeLine::InnerTL.current_tl pb = nil - if(ctl) - options = options.dup - options[:before_post_hook] = lambda{ |this| - get_ancestor(Gtk::Window).set_focus(self) unless self.destroyed? } - pb = Gtk::PostBox.new(options).show_all - postbox.closeup(pb) - pb.on_delete(&Proc.new) if block_given? - get_ancestor(Gtk::Window).set_focus(pb.post) + if ctl + pb = create_postbox(options) ctl.selection.unselect_all end pb end + def postbox_delegation_generator(i_timeline) + ->(params) { + i_timeline.create_postbox(params) } end + private :postbox_delegation_generator + def add_postbox(i_postbox) - reply(i_postbox.options) - end + # ずっと表示される(投稿しても消えない)PostBoxの処理 + # 既にprocっぽいものが入っているときはそのままにしておく + options = i_postbox.options.dup + if options[:delegate_other] && !options[:delegate_other].respond_to?(:to_proc) + i_timeline = i_postbox.ancestor_of(Plugin::GUI::Timeline) + options[:delegate_other] = postbox_delegation_generator(i_timeline) + options[:postboxstorage] = postbox end + create_postbox(options) end def set_cursor_to_display_top iter = model.iter_first @@ -130,8 +146,8 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD # ==== Return # self def clear - deleted = @id_dict - @id_dict = {} + deleted = @iter_dict + @iter_dict = {} deleted.values.each{ |iter| iter[MIRACLE_PAINTER].destroy } model.clear end @@ -139,7 +155,7 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD def get_record(path) iter = model.get_iter(path) if iter - Record.new(iter[0].to_i, iter[1], iter[2], iter[3]) end end + Record.new(iter[0], iter[1], iter[2], iter[3]) end end # _message_ に対応する Gtk::TreePath を返す。なければnilを返す。 def get_path_by_message(message) @@ -147,8 +163,13 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD # _message_ に対応する値の構造体を返す。なければnilを返す。 def get_record_by_message(message) - path = get_path_and_iter_by_message(message)[1] - get_record(path) if path end + get_record_by_uri(message.uri.to_s) end + + # _message_ に対応する値の構造体を返す。なければnilを返す。 + def get_record_by_uri(uri) + path = get_path_and_iter_by_uri(uri)[1] + if path + get_record(path) end end def force_retrieve_in_reply_to if(:auto == @force_retrieve_in_reply_to) @@ -162,21 +183,20 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD # ==== Return # 含まれていれば真 def include?(message) - @id_dict.has_key?(message.id) end + @iter_dict.has_key?(message.uri.to_s) end - # IDとGtk::TreeIterの対を登録する + # Gtk::TreeIterの対を再利用できるように登録しておく # ==== Args - # [id] メッセージID # [iter] Gtk::TreeIter # ==== Return # self - def set_id_dict(iter) - id = iter[MESSAGE_ID].to_i - if not @id_dict.has_key?(id) - @id_dict[id] = iter - iters = @id_dict + def set_iter_dict(iter) + uri = iter[URI] + if not @iter_dict.has_key?(uri) + @iter_dict[uri] = iter + iters = @iter_dict iter[MIRACLE_PAINTER].signal_connect(:destroy) { - iters.delete(id) + iters.delete(uri) false } end self end @@ -199,11 +219,11 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD from.extended from.model.each{ |from_model, from_path, from_iter| iter = model.append - iter[MESSAGE_ID] = from_iter[MESSAGE_ID] + iter[URI] = from_iter[URI] iter[MESSAGE] = from_iter[MESSAGE] iter[ORDER] = from_iter[ORDER] iter[MIRACLE_PAINTER] = from_iter[MIRACLE_PAINTER].set_tree(self) - set_id_dict(iter) } + set_iter_dict(iter) } self end @@ -221,16 +241,24 @@ class Gtk::TimeLine::InnerTL < Gtk::CRUD def get_iter_by_message(message) get_path_and_iter_by_message(message)[2] end + # _message_ に対応する Gtk::TreeIter を返す。なければnilを返す。 + def get_iter_by_uri(uri) + get_path_and_iter_by_uri(uri)[2] end + # _message_ から [model, path, iter] の配列を返す。見つからなかった場合は空の配列を返す。 def get_path_and_iter_by_message(message) - id = message[:id].to_i - if @id_dict[id] - if @id_dict[id][MIRACLE_PAINTER].destroyed? - warn "destroyed miracle painter in cache (##{id})" - @id_dict.delete(id) + get_path_and_iter_by_uri(message.uri.to_s) end + + def get_path_and_iter_by_uri(uri) + uri = uri.to_s + iter = @iter_dict[uri] + if iter + if iter[MIRACLE_PAINTER].destroyed? + warn "destroyed miracle painter in cache (##{uri})" + @iter_dict.delete(uri) [] else - [model, @id_dict[id].path, @id_dict[id]] end + [model, iter.path, iter] end else [] end end diff --git a/core/mui/cairo_markup_generator.rb b/core/mui/cairo_markup_generator.rb index d586c566..e84ff1f8 100644 --- a/core/mui/cairo_markup_generator.rb +++ b/core/mui/cairo_markup_generator.rb @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- require 'gtk2' -miquire :core, 'entity' +miquire :lib, 'retriever' module Pango class << self # テキストをPango.parse_markupで安全にパースできるようにエスケープする。 def escape(text) - text.gsub(/[<>&]/){|m| Message::Entity::ESCAPE_RULE[m] } end + text.gsub(/[<>&]/){|m| Retriever::Entity::TwitterEntity::ESCAPE_RULE[m] } end alias old_parse_markup parse_markup @@ -26,8 +26,8 @@ module Pango module Gdk::MarkupGenerator - ESCAPE_KEYS = Regexp::union(*Message::Entity::ESCAPE_RULE.keys) - ESCAPE_KV = Message::Entity::ESCAPE_RULE.method(:[]) + ESCAPE_KEYS = Regexp::union(*Retriever::Entity::TwitterEntity::ESCAPE_RULE.keys) + ESCAPE_KV = Retriever::Entity::TwitterEntity::ESCAPE_RULE.method(:[]) # 本文を返す def main_text diff --git a/core/mui/cairo_miracle_painter.rb b/core/mui/cairo_miracle_painter.rb index f21245a1..c3251dc3 100644 --- a/core/mui/cairo_miracle_painter.rb +++ b/core/mui/cairo_miracle_painter.rb @@ -70,7 +70,7 @@ class Gdk::MiraclePainter < Gtk::Object @mp_modifier ||= lambda { |miracle_painter| if (not miracle_painter.destroyed?) and (not miracle_painter.tree.destroyed?) miracle_painter.tree.model.each{ |model, path, iter| - if iter[0].to_i == miracle_painter.message[:id] + if iter[0] == miracle_painter.message.uri.to_s miracle_painter.tree.queue_draw break end } end false } end @@ -148,9 +148,14 @@ class Gdk::MiraclePainter < Gtk::Object iob_clicked(x, y) if not textselector_range index = main_pos_to_index(x, y) - if index + if index and message.links.respond_to?(:segment_by_index) l = message.links.segment_by_index(index) - l[:callback].call(l) if l and l[:callback] end end + if l + case + when l[:callback] + l[:callback].call(l) + when l[:open] + Plugin.call(:open, l[:open]) end end end end when 3 @tree.get_ancestor(Gtk::Window).set_focus(@tree) Plugin::GUI::Command.menu_pop @@ -312,7 +317,11 @@ class Gdk::MiraclePainter < Gtk::Object layout end def header_left_markup - Pango.parse_markup("<b>#{Pango.escape(message[:user][:idname])}</b> #{Pango.escape(message[:user][:name] || '')}") + if message.user[:idname] + Pango.parse_markup("<b>#{Pango.escape(message.user.idname)}</b> #{Pango.escape(message.user.name || '')}") + else + Pango.parse_markup(Pango.escape(message.user.name || '')) + end end # ヘッダ(右)のための Pango::Layout のインスタンスを返す @@ -351,7 +360,7 @@ class Gdk::MiraclePainter < Gtk::Object # アイコンのpixbufを返す def main_icon - @main_icon ||= Gdk::WebImageLoader.pixbuf(message[:user][:profile_image_url], icon_width, icon_height){ |pixbuf| + @main_icon ||= Gdk::WebImageLoader.pixbuf(message.user.profile_image_url, icon_width, icon_height){ |pixbuf| if not destroyed? @main_icon = pixbuf on_modify end } end diff --git a/core/mui/cairo_replyviewer.rb b/core/mui/cairo_replyviewer.rb index 520f5277..f132143c 100644 --- a/core/mui/cairo_replyviewer.rb +++ b/core/mui/cairo_replyviewer.rb @@ -25,7 +25,7 @@ class Gdk::ReplyViewer < Gdk::SubPartsMessageBase @edge = show_edge? ? EDGE_PRESENT_SIZE : EDGE_ABSENT_SIZE if helper.message.has_receive_message? helper.message.replyto_source_d(true).next{ |reply| - @messages = Messages.new([reply]).freeze + @messages = [reply].freeze render_messages }.terminate('リプライ描画中にエラーが発生しました') end end diff --git a/core/mui/cairo_sub_parts_message_base.rb b/core/mui/cairo_sub_parts_message_base.rb index f66d9b94..4778c113 100644 --- a/core/mui/cairo_sub_parts_message_base.rb +++ b/core/mui/cairo_sub_parts_message_base.rb @@ -18,7 +18,7 @@ class Gdk::SubPartsMessageBase < Gdk::SubParts # サブクラスで処理を実装すること。 # このメソッドはサブパーツの描画中に何回も呼ばれるので、キャッシュなどで高速化に努めること。 # ==== Return - # _Messages_ | _Array_ :: このSubParts上に表示する _Message_ + # _Array_ :: このSubParts上に表示する _Message_ def messages [] end diff --git a/core/mui/cairo_sub_parts_quote.rb b/core/mui/cairo_sub_parts_quote.rb index f91980c5..926718e4 100644 --- a/core/mui/cairo_sub_parts_quote.rb +++ b/core/mui/cairo_sub_parts_quote.rb @@ -28,7 +28,7 @@ class Gdk::SubPartsQuote < Gdk::SubPartsMessageBase Thread.new(helper.message) { |m| m.quoting_messages(true) }.next{ |quoting| - @messages = Messages.new(quoting).freeze + @messages = quoting.freeze render_messages }.terminate('コメント付きリツイート描画中にエラーが発生しました') end end diff --git a/core/mui/cairo_timeline.rb b/core/mui/cairo_timeline.rb index f2d8a75c..773dec91 100644 --- a/core/mui/cairo_timeline.rb +++ b/core/mui/cairo_timeline.rb @@ -24,14 +24,6 @@ class Gtk::TimeLine attr_reader :tl - Message::Entity.addlinkrule(:urls, URI.regexp(['http','https'])){ |segment| - Gtk::TimeLine.openurl(segment[:expanded_url].empty? ? segment[:url] : segment[:expanded_url]) - } - - Message::Entity.addlinkrule(:media){ |segment| - Gtk::TimeLine.openurl(segment[:url]) - } - # 現在アクティブなTLで選択されているすべてのMessageオブジェクトを返す def self.get_active_mumbles if Gtk::TimeLine::InnerTL.current_tl @@ -140,8 +132,7 @@ class Gtk::TimeLine # _message_ をTLに追加する def block_add(message) if not @tl.destroyed? - raise "id must than 1 but specified #{message[:id].inspect}" if message[:id] <= 0 - if(!any?{ |m| m[:id] == message[:id] }) + if(!any?{ |m| m == message }) case when message[:rule] == :destroy remove_if_exists_all([message]) @@ -162,11 +153,11 @@ class Gtk::TimeLine scroll_to_zero_lator! if @tl.realized? and @tl.vadjustment.value == 0.0 miracle_painter = @tl.cell_renderer_message.create_miracle_painter(message) iter = @tl.model.append - iter[Gtk::TimeLine::InnerTL::MESSAGE_ID] = message[:id].to_s + iter[Gtk::TimeLine::InnerTL::URI] = message.uri.to_s iter[Gtk::TimeLine::InnerTL::MESSAGE] = message iter[Gtk::TimeLine::InnerTL::ORDER] = get_order(message) iter[Gtk::TimeLine::InnerTL::MIRACLE_PAINTER] = miracle_painter - @tl.set_id_dict(iter) + @tl.set_iter_dict(iter) @remover_queue.push(message) if @tl.realized? self end diff --git a/core/mui/gtk_extension.rb b/core/mui/gtk_extension.rb index 14cb6f2c..1ca1fabc 100644 --- a/core/mui/gtk_extension.rb +++ b/core/mui/gtk_extension.rb @@ -59,7 +59,7 @@ class GLib::Instantiatable proc.call(*args) rescue Exception => e now = caller.size + 1 # proc.callのぶんスタックが1つ多い - $@ = e.backtrace[0, e.backtrace.size - now] + trace + #$@ = e.backtrace[0, e.backtrace.size - now] + trace Gtk.exception = e into_debug_mode(e, proc.binding) raise e end @@ -282,46 +282,8 @@ module Gtk # _url_ を設定されているブラウザで開く class << self def openurl(url) - command = nil - if UserConfig[:url_open_specified_command] - command = UserConfig[:url_open_command] - bg_system(command, url) - elsif(defined? Win32API) then - shellExecuteA = Win32API.new('shell32.dll','ShellExecuteA',%w(p p p p p i),'i') - shellExecuteA.call(0, 'open', url, 0, 0, 1) - else - command = Gtk::url_open_command - if(command) - bg_system(command, url) - else - Plugin.activity :system, "この環境で、URLを開くためのコマンドが判別できませんでした。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。" end end - rescue => exception - title = "コマンド \"#{command}\" でURLを開こうとしましたが、開けませんでした。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。" - description = { - title: title, - message: exception.to_s, - backtrace: exception.backtrace.join("\n") } - Plugin.activity :system, title, - error: exception, - description: "%{title}\n\n%{message}\n\n%{backtrace}" % description + Plugin.call(:open, URI(url)) end - - # URLを開くことができるコマンドを返す。 - def url_open_command - openable_commands = %w{xdg-open open /etc/alternatives/x-www-browser} - wellknown_browsers = %w{firefox chromium opera} - command = nil - catch(:urlopen) do - openable_commands.each{ |o| - if command_exist?(o) - command = o - throw :urlopen end } - wellknown_browsers.each{ |o| - if command_exist?(o) - Plugin.activity :system, "この環境で、URLを開くためのコマンドが判別できなかったので、\"#{command}\"を使用します。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。" - command = o - throw :urlopen end } end - command end end end diff --git a/core/mui/gtk_inneruserlist.rb b/core/mui/gtk_inneruserlist.rb index fb9d5b12..5ff5937d 100644 --- a/core/mui/gtk_inneruserlist.rb +++ b/core/mui/gtk_inneruserlist.rb @@ -22,12 +22,12 @@ class Gtk::InnerUserList < Gtk::TreeView # Userの配列 _users_ を追加する # ==== Args - # [users] ユーザの配列 + # [users] Enumerable ユーザを繰り返すEnumerable # ==== Return # self def add_user(users) - type_strict users => Users - (users - model.to_enum.map{ |model,path,iter| iter[COL_USER] }).deach { |user| + exist_users = Set.new(model.to_enum.map{ |model,path,iter| iter[COL_USER] }) + users.reject{|user| exist_users.include? user }.deach { |user| iter = model.append iter[COL_ICON] = Gdk::WebImageLoader.pixbuf(user[:profile_image_url], 24, 24){ |pixbuf| if not destroyed? @@ -45,7 +45,6 @@ class Gtk::InnerUserList < Gtk::TreeView # ==== Return # self def remove_user(users) - type_strict users => Users Enumerator.new(model).each{ |model,path,iter| if users.include?(iter[COL_USER]) model.remove(iter) end } @@ -57,7 +56,6 @@ class Gtk::InnerUserList < Gtk::TreeView # ==== Return # self def reorder(user) - type_strict user => User each{ |m, p, iter| if iter[COL_USER] == user iter[COL_ORDER] = @userlist.gen_order(user) diff --git a/core/mui/gtk_intelligent_textview.rb b/core/mui/gtk_intelligent_textview.rb index 33251694..faaa9931 100644 --- a/core/mui/gtk_intelligent_textview.rb +++ b/core/mui/gtk_intelligent_textview.rb @@ -118,10 +118,6 @@ class Gtk::IntelligentTextview < Gtk::TextView self.signal_connect('event'){ set_cursor(self, Gdk::Cursor::XTERM) false } -# self.signal_connect('button_release_event'){ |widget, event| -# Gtk::Lock.synchronize{ -# menu_pop(widget) if (event.button == 3) } -# false } end def create_tag_ifnecessary(tagname, buffer, leftclick, rightclick) @@ -168,9 +164,3 @@ class Gtk::IntelligentTextview < Gtk::TextView self.add_child_at_anchor(widget, buffer.create_child_anchor(range[1])) offset += 1 end } } end end - -Plugin.create :gtk_intelligent_textview do - on_entity_linkrule_added do |rule| - ::Gtk::IntelligentTextview.addlinkrule(rule[:regexp], lambda{ |seg, tv| rule[:callback].call(face: seg, url: seg, textview: tv) }) if rule[:regexp] - end -end diff --git a/core/plugin/message_detail_view/header_widget.rb b/core/mui/gtk_retriever_header_widget.rb index 6cb63472..4f3d67ac 100644 --- a/core/plugin/message_detail_view/header_widget.rb +++ b/core/mui/gtk_retriever_header_widget.rb @@ -1,17 +1,22 @@ # -*- coding: utf-8 -*- -module Plugin::MessageInspector - class HeaderWidget < Gtk::EventBox - def initialize(message, *args) +module Gtk + # message_detail_viewプラグインなどで使われている、ヘッダ部分のユーザ情報。 + # コンストラクタにはUserではなくMessageなど、userを保持しているRetrieverを渡すことに注意。 + # このウィジェットによって表示されるタイムスタンプをクリックすると、 + # コンストラクタに渡されたretrieverのperma_linkを開くようになっている。 + class RetrieverHeaderWidget < Gtk::EventBox + def initialize(retriever, *args) + type_strict retriever => Retriever::Model super(*args) ssc_atonce(:visibility_notify_event, &widget_style_setter) add(Gtk::VBox.new(false, 0). closeup(Gtk::HBox.new(false, 0). - closeup(icon(message.user).top). + closeup(icon(retriever.user).top). closeup(Gtk::VBox.new(false, 0). - closeup(idname(message.user).left). - closeup(Gtk::Label.new(message.user[:name]).left))). - closeup(post_date(message).right)) + closeup(idname(retriever.user).left). + closeup(Gtk::Label.new(retriever.user[:name]).left))). + closeup(post_date(retriever).right)) end private @@ -28,7 +33,7 @@ module Plugin::MessageInspector .set_padding(*[UserConfig[:profile_icon_margin]]*4) icon = Gtk::EventBox.new. - add(icon_alignment.add(Gtk::WebIcon.new(user.profile_image_url_large, UserConfig[:profile_icon_size], UserConfig[:profile_icon_size]).tooltip(Plugin[:message_detail_view]._('アイコンを開く')))) + add(icon_alignment.add(Gtk::WebIcon.new(user.profile_image_url_large, UserConfig[:profile_icon_size], UserConfig[:profile_icon_size]))) icon.ssc(:button_press_event, &icon_opener(user.profile_image_url_large)) icon.ssc_atonce(:realize, &cursor_changer(Gdk::Cursor.new(Gdk::Cursor::HAND2))) icon.ssc_atonce(:visibility_notify_event, &widget_style_setter) @@ -43,10 +48,10 @@ module Plugin::MessageInspector label.ssc_atonce(:visibility_notify_event, &widget_style_setter) label end - def post_date(message) + def post_date(retriever) label = Gtk::EventBox.new. - add(Gtk::Label.new(message.created.strftime('%Y/%m/%d %H:%M:%S'))) - label.ssc(:button_press_event, &message_opener(message)) + add(Gtk::Label.new(retriever.created.strftime('%Y/%m/%d %H:%M:%S'))) + label.ssc(:button_press_event, &message_opener(retriever)) if retriever.perma_link label.ssc_atonce(:realize, &cursor_changer(Gdk::Cursor.new(Gdk::Cursor::HAND2))) label.ssc_atonce(:visibility_notify_event, &widget_style_setter) label end @@ -58,15 +63,15 @@ module Plugin::MessageInspector true end end def profile_opener(user) - type_strict user => User + type_strict user => Retriever::Model proc do Plugin.call(:show_profile, Service.primary, user) true end end def message_opener(message) - type_strict message => Message + type_strict message => Retriever::Model proc do - Gtk.openurl(message.perma_link) + Gtk.openurl(retriever.perma_link) true end end memoize def cursor_changer(cursor) diff --git a/core/mui/gtk_timeline_utils.rb b/core/mui/gtk_timeline_utils.rb index 52dea9d4..0726bd1a 100644 --- a/core/mui/gtk_timeline_utils.rb +++ b/core/mui/gtk_timeline_utils.rb @@ -80,7 +80,7 @@ module Gtk::TimeLineUtils # _message_ を追加する。配列で複数のMessageオブジェクトを渡すこともできる。 def add(message) - if message.is_a?(Enumerable) then + if message.is_a?(Enumerable) self.block_add_all(Plugin.filtering(:show_filter, message).first) else m = Plugin.filtering(:show_filter, [message]).first.first diff --git a/core/mui/gtk_userlist.rb b/core/mui/gtk_userlist.rb index ffe87d06..6308bad8 100644 --- a/core/mui/gtk_userlist.rb +++ b/core/mui/gtk_userlist.rb @@ -34,7 +34,7 @@ class Gtk::UserList < Gtk::EventBox # Userの配列 _users_ を追加する # ==== Args - # [users] ユーザの配列 + # [users] Enumerable ユーザを繰り返すEnumerable # ==== Return # self def add_user(users) diff --git a/core/plugin/activity/activity.rb b/core/plugin/activity/activity.rb index 57c38df1..f3714195 100644 --- a/core/plugin/activity/activity.rb +++ b/core/plugin/activity/activity.rb @@ -182,7 +182,7 @@ Plugin.create(:activity) do iter[ActivityView::SERVICE] = params[:service] iter[ActivityView::EVENT] = params if (UserConfig[:activity_show_timeline] || []).map(&:to_s).include?(params[:kind].to_s) - Plugin.call(:update, nil, [Message.new(message: params[:description], system: true, source: params[:plugin].to_s, created: params[:date])]) + Plugin.call(:update, nil, [Mikutter::System::Message.new(description: params[:description], source: params[:plugin].to_s, created: params[:date])]) end if (UserConfig[:activity_show_statusbar] || []).map(&:to_s).include?(params[:kind].to_s) Plugin.call(:gui_window_rewindstatus, Plugin::GUI::Window.instance(:default), "#{params[:kind]}: #{params[:title]}", 10) @@ -193,8 +193,7 @@ Plugin.create(:activity) do on_favorite do |service, user, message| activity(:favorite, "#{message.user[:idname]}: #{message.to_s}", description:(_("@%{user} がふぁぼふぁぼしました") % {user: user[:idname]} + "\n" + - "@#{message.user[:idname]}: #{message.to_s}\n"+ - message.perma_link), + "@#{message.user[:idname]}: #{message.to_s}\n#{message.perma_link}"), icon: user[:profile_image_url], related: message.user.me? || user.me?, service: service) @@ -203,8 +202,7 @@ Plugin.create(:activity) do on_unfavorite do |service, user, message| activity(:unfavorite, "#{message.user[:idname]}: #{message.to_s}", description:(_("@%{user} があんふぁぼしました") % {user: user[:idname]} + "\n" + - "@#{message.user[:idname]}: #{message.to_s}\n"+ - message.perma_link), + "@#{message.user[:idname]}: #{message.to_s}\n#{message.perma_link}"), icon: user[:profile_image_url], related: message.user.me? || user.me?, service: service) @@ -215,8 +213,7 @@ Plugin.create(:activity) do retweet.retweet_source_d.next{ |source| activity(:retweet, retweet.to_s, description:(_("@%{user} がリツイートしました") % {user: retweet.user[:idname]} + "\n" + - "@#{source.user[:idname]}: #{source.to_s}\n"+ - source.perma_link), + "@#{source.user[:idname]}: #{source.to_s}\n#{source.perma_link}"), icon: retweet.user[:profile_image_url], date: retweet[:created], related: (retweet.user.me? || source && source.user.me?), diff --git a/core/plugin/change_account/interactive.rb b/core/plugin/change_account/interactive.rb index 36a599dc..5e65e86d 100644 --- a/core/plugin/change_account/interactive.rb +++ b/core/plugin/change_account/interactive.rb @@ -6,12 +6,11 @@ module Plugin::ChangeAccount self.next { promise = Deferred.new(true).extend(InteractiveMixin) Plugin.call(:update, nil, - [Message.new(message: message, - system: true, - source: "change_account", - created: Time.now, - confirm: choose, - confirm_callback: promise)]) + [Mikutter::System::Message.new(description: message, + source: "change_account", + created: Time.now, + confirm: choose, + confirm_callback: promise)]) promise } end diff --git a/core/plugin/command/command.rb b/core/plugin/command/command.rb index 79b3ef32..c5677fc6 100644 --- a/core/plugin/command/command.rb +++ b/core/plugin/command/command.rb @@ -135,6 +135,13 @@ Plugin.create :command do role: :timeline) do |opt| ::Gtk::openurl("http://www.google.co.jp/search?q=" + URI.escape(opt.widget.selected_text(opt.messages.first)).to_s) end + command(:open_in_browser, + name: _('ブラウザで開く'), + condition: Plugin::Command[:HasOneMessage, :HasParmaLinkAll], + visible: true, + role: :timeline) do |opt| + ::Gtk::openurl(opt.messages.first.perma_link) end + command(:open_link, name: _('リンクを開く'), condition: Plugin::Command[:HasOneMessage] & lambda{ |opt| diff --git a/core/plugin/command/conditions.rb b/core/plugin/command/conditions.rb index 25b1c8d2..c7bb07f1 100644 --- a/core/plugin/command/conditions.rb +++ b/core/plugin/command/conditions.rb @@ -81,9 +81,15 @@ module ::Plugin::Command # TL上のテキストが一文字でも選択されている TimelineTextSelected = Condition.new{ |opt| opt.widget.selected_text(opt.messages.first) } + HasParmaLinkAll = Condition.new{ |opt| + not opt.messages.empty? and opt.messages.all? { |m| m.perma_link } } + # ==== postbox ロール # 編集可能状態(入力中:グレーアウトしてない時) Editable = Condition.new{ |opt| opt.widget.editable? } end + + + diff --git a/core/plugin/core/core.rb b/core/plugin/core/core.rb index c7f293b9..c3fd7a8f 100644 --- a/core/plugin/core/core.rb +++ b/core/plugin/core/core.rb @@ -43,6 +43,8 @@ Plugin.create :core do filter_mention(&gen_message_filter_with_service) + filter_direct_messages(&gen_message_filter_with_service) + filter_appear(&gen_message_filter) # リツイートを削除した時、ちゃんとリツイートリストからそれを削除する @@ -54,8 +56,6 @@ Plugin.create :core do Plugin.call(:retweet_destroyed, source, message.user, message[:id]) source.retweeted_sources.delete(message) end end } end - on_entity_linkrule_added(&Message::Entity.method(:on_entity_linkrule_added)) - end Module.new do diff --git a/core/plugin/direct_message/direct_message.rb b/core/plugin/direct_message/direct_message.rb index 329ec3c7..d268f932 100644 --- a/core/plugin/direct_message/direct_message.rb +++ b/core/plugin/direct_message/direct_message.rb @@ -6,17 +6,9 @@ require File.expand_path File.join(File.dirname(__FILE__), 'dmlistview') module Plugin::DirectMessage Plugin.create(:direct_message) do - def userlist @userlist ||= UserList.new end - # user_id => [Direct Message...] - @dm_store = Hash.new{|h, k| - Plugin.call(:direct_message_add_user, k) - h[k] = [] } - # user_id => created_at(Integer) - userlist.dm_last_date = @dm_last_date = Hash.new - @dm_lock = Mutex.new @counter = gen_counter ul = userlist tab(:directmessage, _("DM")) do @@ -27,96 +19,63 @@ module Plugin::DirectMessage user_fragment(:directmessage, _("DM")) do set_icon Skin.get("directmessage.png") - nativewidget Plugin[:direct_message].dm_list_widget(retriever) + u = user + timeline timeline_name_for(u) do + postbox(from: Sender.new(u), delegate_other: true) + end + end + + filter_extract_datasources do |datasources| + datasources = { + direct_message: _("ダイレクトメッセージ"), + }.merge datasources + Service.map{ |service| + user = service.user_obj + datasources.merge!({ extract_slug_for(user) => "@#{user.idname}/" + _("ダイレクトメッセージ") }) + } + [datasources] end + + def extract_slug_for(user) + "direct_message-#{user.id}".to_sym + end + + on_direct_messages do |_, dms| + dm_distribution = Hash.new {|h,k| h[k] = []} + dms.each do |dm| + model = Mikutter::Twitter::DirectMessage.new_ifnecessary(dm) + dm_distribution[model[:user]] << model + dm_distribution[model[:recipient]] << model + end + dm_distribution.each do |to_user, dm_for_user| + Plugin::GUI::Timeline.instance(timeline_name_for(to_user)) << dm_for_user + Plugin.call :extract_receive_message, timeline_name_for(to_user), dm_for_user + end + Plugin.call :extract_receive_message, :direct_message, dms + ul.update(dm_distribution.map{|k, v| [k, v.map{|dm| dm[:created]}.max]}.to_h) + end + + def timeline_name_for(user) + :"direct_messages_from_#{user.idname}" end onperiod do if 0 == (@counter.call % UserConfig[:retrieve_interval_direct_messages]) rewind end end - filter_direct_messages do |service, dms| - if defined? dms.sort_by - result = [] - @dm_lock.synchronize do - dms.sort_by{ |s| Time.parse(s[:created_at]) rescue Time.now }.each { |dm| - if add_dm(dm, dm[:sender]) and (dm[:sender] == dm[:recipient] || add_dm(dm, dm[:recipient])) - result << dm end } end - [service, result] - else - [service, dms] end end - - on_direct_message_add_user do |user_id| - user = User.findbyid(user_id) - if user.is_a? User - userlist.add_user(Users.new([user])) end end - def rewind - service = Service.primary_service + service = Service.primary if service - Deferred.when(service.direct_messages, service.sent_direct_messages).next{ |dm, sent| + Deferred.when( + service.direct_messages(cache: :keep), + service.sent_direct_messages(cache: :keep) + ).next{ |dm, sent| result = dm + sent - Plugin.call(:direct_messages, service, result) if result and not result.empty? + Plugin.call(:direct_messages, service, result) unless result.empty? }.trap{ |e| error e raise e }.terminate end end - def add_dm(dm, user) - unless @dm_store[user[:id]].any?{ |stored| stored[:id] == dm[:id] } - created_at = Time.parse(dm[:created_at]).to_i - if not(@dm_last_date.has_key?(user.id)) or @dm_last_date[user.id] < created_at - @dm_last_date[user.id] = created_at - Delayer.new{ userlist.reorder(user) } end - @dm_store[user[:id]] << dm end - end - - def dm_list_widget(user) - container = ::Gtk::VBox.new - tl = DirectMessage.new(self) - - scrollbar = ::Gtk::VScrollbar.new(tl.vadjustment) - model = tl.model - @dm_lock.synchronize do - if @dm_store.has_key?(user[:id]) - @dm_store[user[:id]].each { |dm| - iter = model.append - iter[DirectMessage::C_CREATED] = Time.parse(dm[:created_at]).to_i - iter[DirectMessage::C_ICON] = Gdk::WebImageLoader.pixbuf(dm[:sender][:profile_image_url], 16, 16) { |pixbuf| - iter[DirectMessage::C_ICON] = pixbuf } - iter[DirectMessage::C_TEXT] = dm[:text] - iter[DirectMessage::C_RAW] = dm } end end - - event = on_direct_messages do |service, dms| - if not tl.destroyed? - dms.each{ |dm| - if user[:id].to_i == dm[:sender][:id].to_i or user[:id].to_i == dm[:recipient][:id].to_i - iter = model.append - iter[DirectMessage::C_CREATED] = Time.parse(dm[:created_at]).to_i - iter[DirectMessage::C_ICON] = Gdk::WebImageLoader.pixbuf(dm[:sender][:profile_image_url], 16, 16) { |pixbuf| - iter[DirectMessage::C_ICON] = pixbuf } - iter[DirectMessage::C_TEXT] = dm[:text] - iter[DirectMessage::C_RAW] = dm end } end end - - tl.ssc(:scroll_event){ |this, e| - case e.direction - when Gdk::EventScroll::UP - this.vadjustment.value -= this.vadjustment.step_increment - when Gdk::EventScroll::DOWN - this.vadjustment.value += this.vadjustment.step_increment end - false } - - tl.ssc(:destroy){ - detach(:direct_message, event) - } - mumbles = ::Gtk::VBox.new(false, 0) - postbox = ::Gtk::PostBox.new(from: Sender.new(user), - postboxstorage: mumbles, - delegate_other: true) - mumbles.pack_start(postbox) - container.closeup(mumbles).add(::Gtk::HBox.new.add(tl).closeup(scrollbar)) - container - end - rewind end diff --git a/core/plugin/direct_message/userlist.rb b/core/plugin/direct_message/userlist.rb index 03fe956d..7a243efc 100644 --- a/core/plugin/direct_message/userlist.rb +++ b/core/plugin/direct_message/userlist.rb @@ -3,10 +3,19 @@ # 最後にやりとりしたDMの日時でソートする機能のついたUserlist module Plugin::DirectMessage class UserList < Gtk::UserList - attr_accessor :dm_last_date + def initialize + super + @dm_last_date = Hash.new + end def gen_order(user) - dm_last_date[user.id] || 0 end + @dm_last_date[user.id] || 0 end + def update(update_hash) + update_hash.each do |user, last_date| + @dm_last_date[user[:id]] = last_date.to_i + end + add_user(Users.new(update_hash.keys)) + end end end diff --git a/core/plugin/display_requirements/display_requirements.rb b/core/plugin/display_requirements/display_requirements.rb index a78b9390..eb9ce283 100644 --- a/core/plugin/display_requirements/display_requirements.rb +++ b/core/plugin/display_requirements/display_requirements.rb @@ -49,10 +49,6 @@ Plugin.create :display_requirements do EventFilter.cancel! if :search_hashtag == options[:filter_id] [options] end - Message::Entity.addlinkrule(:hashtags, /(?:#|#)[a-zA-Z0-9_]+/, :open_in_browser_hashtag){ |segment| - Gtk.openurl("https://twitter.com/search/realtime?q="+CGI.escape(segment[:url].match(/\A(?:#|#)?.+\Z/)[0])) - } - # いいね filter_skin_get do |filename, fallback_dirs| case filename @@ -131,7 +127,7 @@ class ::Gdk::MiraclePainter # 必ず名前のあとにスクリーンネームを表示しなければいけない。 # また、スクリーンネームの前には必ず @ が必要。 def header_left_markup - Pango.parse_markup("<b>#{Pango.escape(message[:user][:name] || '')}</b> @#{Pango.escape(message[:user][:idname])}") + Pango.parse_markup("<b>#{Pango.escape(message.user.name || '')}</b> @#{Pango.escape(message.user.idname)}") end # 時刻の表記は必ず相対表記にしなければいけない。 diff --git a/core/plugin/gtk/po/ja/gtk.po b/core/plugin/gtk/po/ja/gtk.po index 7ad0dcb4..e22ed12a 100644 --- a/core/plugin/gtk/po/ja/gtk.po +++ b/core/plugin/gtk/po/ja/gtk.po @@ -20,4 +20,4 @@ msgstr "" #: ../mikutter_window.rb:64 msgid "Statusbar default message" -msgstr "mikutter 果てしないTwitterライフのために。" +msgstr "これが、3.5" diff --git a/core/plugin/gui/gui.rb b/core/plugin/gui/gui.rb index 01b4430b..9daf39b8 100644 --- a/core/plugin/gui/gui.rb +++ b/core/plugin/gui/gui.rb @@ -36,8 +36,10 @@ Plugin.create :gui do # [slug] タイムラインのスラッグ # ==== Return # Plugin::GUI::Timeline - defdsl :timeline do |slug| - Plugin::GUI::Timeline.instance(slug) end + defdsl :timeline do |slug, &proc| + tl = Plugin::GUI::Timeline.instance(slug) + tl.instance_eval(&proc) if proc + tl end # プロフィールタブを定義する # ==== Args @@ -48,7 +50,7 @@ Plugin.create :gui do tabs.insert(where_should_insert_it(slug, tabs.map(&:first), UserConfig[:profile_tab_order]), [slug, -> { - fragment_slug = "#{slug}_#{user.idname}_#{Process.pid}_#{Time.now.to_i.to_s(16)}_#{rand(2 ** 32).to_s(16)}".to_sym + fragment_slug = "#{slug}_#{user.uri}_#{Process.pid}_#{Time.now.to_i.to_s(16)}_#{rand(2 ** 32).to_s(16)}".to_sym i_fragment = Plugin::GUI::Fragment.instance(fragment_slug, title) i_cluster << i_fragment i_fragment.instance_eval{ @retriever = user } @@ -67,7 +69,7 @@ Plugin.create :gui do tabs.insert(where_should_insert_it(slug, tabs.map(&:first), UserConfig[:profile_tab_order]), [slug, -> { - fragment_slug = "#{slug}_#{message.id}_#{Process.pid}_#{Time.now.to_i.to_s(16)}_#{rand(2 ** 32).to_s(16)}".to_sym + fragment_slug = "#{slug}_#{message.uri}_#{Process.pid}_#{Time.now.to_i.to_s(16)}_#{rand(2 ** 32).to_s(16)}".to_sym i_fragment = Plugin::GUI::Fragment.instance(fragment_slug, title) i_cluster << i_fragment i_fragment.instance_eval{ @retriever = message } diff --git a/core/plugin/gui/postbox.rb b/core/plugin/gui/postbox.rb index 0dac6fe5..e9f6d3fa 100644 --- a/core/plugin/gui/postbox.rb +++ b/core/plugin/gui/postbox.rb @@ -17,6 +17,8 @@ class Plugin::GUI::Postbox role :postbox + set_parent_event :gui_postbox_join_widget + set_parent_class Plugin::GUI::Postbox::PostboxParent attr_accessor :options @@ -37,12 +39,6 @@ class Plugin::GUI::Postbox @options = {} Plugin.call(:postbox_created, self) end - alias __set_parent_postbox__ set_parent - def set_parent(parent) - Plugin.call(:gui_postbox_join_widget, self, parent) - __set_parent_postbox__(parent) - end - # このPostboxの内容を投稿する # ==== Return # self diff --git a/core/plugin/gui/timeline.rb b/core/plugin/gui/timeline.rb index 82b2f688..32986005 100644 --- a/core/plugin/gui/timeline.rb +++ b/core/plugin/gui/timeline.rb @@ -23,18 +23,8 @@ class Plugin::GUI::Timeline end def <<(argument) - messages = - case argument - when Enumerator::Lazy, Messages - argument - when Enumerable - Messages.new(argument) - else - Messages.new([argument]) end + messages = argument.is_a?(Enumerable) ? argument : Array[argument] Plugin.call(:gui_timeline_add_messages, self, messages) - rescue TypedArray::UnexpectedTypeException => e - error "type mismatch!" - raise e end # タイムラインの中のツイートを全て削除する @@ -58,7 +48,7 @@ class Plugin::GUI::Timeline # ==== Return # _messages_ が含まれているなら真 def include?(*messages) - args = Messages.new([messages].flatten).freeze + args = messages.flatten.freeze detected = Plugin.filtering(:gui_timeline_select_messages, self, args) detected.is_a? Array and detected[1].size == args.size end @@ -72,7 +62,7 @@ class Plugin::GUI::Timeline if detected.is_a? Enumerable detected[1] else - Messages.new([]) end end + [] end end # _messages_ のうち、Timelineに含まれていないMessageを返す # ==== Args @@ -84,7 +74,7 @@ class Plugin::GUI::Timeline if detected.is_a? Enumerable detected[1] else - Messages.new([]) end end + [] end end # 選択されているMessageを返す # ==== Return diff --git a/core/plugin/image_file_cache/Gemfile b/core/plugin/image_file_cache/Gemfile new file mode 100644 index 00000000..f86005fe --- /dev/null +++ b/core/plugin/image_file_cache/Gemfile @@ -0,0 +1 @@ +gem 'moneta' diff --git a/core/plugin/intent/.mikutter.yml b/core/plugin/intent/.mikutter.yml new file mode 100644 index 00000000..250496fd --- /dev/null +++ b/core/plugin/intent/.mikutter.yml @@ -0,0 +1,11 @@ +--- +slug: :intent +depends: + mikutter: "3.5" + plugin: [] +version: '1.0' +author: toshi_a +name: intent +description: >- + ModelをUI上で開く機能を提供する。 + Pluginが特定のModelを扱えることを表明するintent DSLメソッドを提供する。 diff --git a/core/plugin/intent/intent.rb b/core/plugin/intent/intent.rb new file mode 100644 index 00000000..862a1a84 --- /dev/null +++ b/core/plugin/intent/intent.rb @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +require_relative 'model/intent' +require_relative 'model/intent_token' + +Plugin.create(:intent) do + # _uri_ を開くことができる Model を列挙するためのフィルタ + defevent :model_of_uri, + prototype: [URI, :<<] + + # _model_slug_ を開くことができる Intent を列挙するためのフィルタ + defevent :intent_select_by_model_slug, + prototype: [Symbol, :<<] + + # 第二引数のリソースを、第一引数のIntentのうちどれで開くかを決められなかった時に発生する。 + # intent_selectorプラグインがこれを受け取ってダイアログとか出す + defevent :intent_select, + prototype: [Enumerable, tcor(URI, String, Retriever::Model)] + + # _model_ を開く方法を新しく登録する。 + # ==== Args + # [model] サポートするModelのClass + # [label:] + # 開き方を説明する文字列。 + # あるModelを開く手段が複数ある場合、ユーザは _label_ の内容とともに、どうやって開くか選択することになる。 + # 省略した場合はpluginの名前になる + # [slug:] + # このintentのslug。他のintentと重複してはならない。 + # 通常は指定しなくてもユニークなslugが割り当てられるが、同じ _model_ に二つ以上のintentを登録する場合は、同じslugが自動生成されてしまうので、ユニークな値を設定しなければならない。 + # 省略した場合はPluginとModelから自動生成される + # [&proc] + # パーマリンクを開く時に、 Plugin::Intent::IntentToken を引数に呼ばれる。 + # ==== Return + # self + defdsl :intent do |model, label: nil, slug: :"#{self.spec[:slug]}_#{model.slug}", &proc| + label ||= (self.spec[:name] || self.spec[:slug]) + my_intent = Plugin::Intent::Intent.new(slug: slug, label: label, model_slug: model.slug) + filter_intent_select_by_model_slug do |target_model_slug, intents| + if model.slug == target_model_slug + intents << my_intent + end + [target_model_slug, intents] + end + add_event(:"intent_open_#{slug}", &proc) + self + end + + on_open do |object| + case object + when Plugin::Intent::IntentToken + Plugin.call("intent_open_#{object.intent.slug}", object) + when Retriever::Model + open_model(object) + when String, URI + open_uri(object.is_a?(URI) ? object : URI.parse(object)) + end + end + + # _uri_ をUI上で開く。 + # このメソッドが呼ばれたらIntentTokenを生成して、開くことを試みる。 + # open_modelのほうが高速なので、modelオブジェクトが存在するならばopen_modelを呼ぶこと。 + # ==== Args + # [uri] 対象となるURI + def open_uri(uri) + model_slugs = Plugin.filtering(:model_of_uri, uri.freeze, Set.new).last + if model_slugs.empty? + error "model not found to open for #{uri}" + return + end + intents = model_slugs.inject(Set.new) do |memo, model_slug| + memo.merge(Plugin.filtering(:intent_select_by_model_slug, model_slug, Set.new).last) + end + if intents.empty? + error "intent not found to open for #{model_slugs.to_a}" + return + end + if intents.size == 1 + intent = intents.to_a.first + Plugin::Intent::IntentToken.open( + uri: uri, + intent: intent, + parent: nil) + else + Plugin.call(:intent_select, intents, uri) + end + end + + # _model_ をUI上で開く。 + # このメソッドが呼ばれたらIntentTokenを生成して、開くことを試みる。 + # open_uriは、Modelが必要になった時にURIからModelの取得生成を試みるが、 + # このメソッドはヒントとして _model_ を与えるため、探索が発生せず高速に処理できる。 + # ==== Args + # [model] 対象となるRetriever::Model + def open_model(model) + intents = Plugin.filtering(:intent_select_by_model_slug, model.class.slug, Set.new).last + # TODO: intents をユーザに選択させる + if intents.size == 1 + intent = intents.to_a.first + Plugin::Intent::IntentToken.open( + uri: model.uri, + model: model, + intent: intent, + parent: nil) + else + Plugin.call(:intent_select, intents, model) + end + end +end diff --git a/core/plugin/intent/model/intent.rb b/core/plugin/intent/model/intent.rb new file mode 100644 index 00000000..139d69f0 --- /dev/null +++ b/core/plugin/intent/model/intent.rb @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +module Plugin::Intent + class Intent < Retriever::Model + field.string :slug, required: true # Intentのslug + field.string :label, required: true + field.string :model_slug, required: true + end +end diff --git a/core/plugin/intent/model/intent_token.rb b/core/plugin/intent/model/intent_token.rb new file mode 100644 index 00000000..2279c0ce --- /dev/null +++ b/core/plugin/intent/model/intent_token.rb @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +module Plugin::Intent + class IntentToken < Retriever::Model + field.string :uri, required: true + field.has :model, Retriever::Model + field.has :intent, Plugin::Intent::Intent, required: true + + # 引数の情報からIntentTokenを作成し、それを開く + def self.open(*args) + self.new(*args).open + end + + # 設定された情報を使ってURI又はModelを開く + def open + if model? + Plugin.call(:open, self) + else + Deferred.new{ + Retriever.Model(intent.model_slug).find_by_uri(uri) + }.next{|m| + self.model = m + Plugin.call(:open, self) + }.terminate('なんか開けなかった') + end + self + end + end +end diff --git a/core/plugin/intent_selector/.mikutter.yml b/core/plugin/intent_selector/.mikutter.yml new file mode 100644 index 00000000..1c5ad141 --- /dev/null +++ b/core/plugin/intent_selector/.mikutter.yml @@ -0,0 +1,12 @@ +--- +slug: :intent_selector +depends: + mikutter: 3.5.0-develop + plugin: + - gtk + - intent +version: '1.0' +author: toshi_a +name: Intent Selector +description: >- + 開く方法が複数見つかった時、Gtkでダイアログを表示して、どの方法で開くかをユーザに入力させる diff --git a/core/plugin/intent_selector/intent_selector.rb b/core/plugin/intent_selector/intent_selector.rb new file mode 100644 index 00000000..07bafcf8 --- /dev/null +++ b/core/plugin/intent_selector/intent_selector.rb @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +Plugin.create(:intent_selector) do + on_intent_select do |intents, model| + case model + when Retriever::Model + intent_choose_dialog(intents, model: model) + when URI + intent_choose_dialog(intents, uri: model) + when String + intent_choose_dialog(intents, model: URI.parse(model)) + end + end + + def intent_choose_dialog(intents, model: nil, uri: model.uri) + dialog = Gtk::Dialog.new('開く - %{application_name}' % {application_name: Environment::NAME}) + dialog.window_position = Gtk::Window::POS_CENTER + dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK) + dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL) + dialog.vbox.closeup(Gtk::Label.new("%{uri} \nを開こうとしています。どの方法で開きますか?" % {uri: uri}, false)) + selected_intent = nil + intents.inject(nil) do |group, intent| + if group + radio = Gtk::RadioButton.new(group, intent.label) + else + selected_intent = intent + radio = Gtk::RadioButton.new(intent.label) end + radio.ssc(:toggled) do |w| + selected_intent = intent + false + end + radio.ssc(:activate) do |w| + selected_intent = intent + dialog.signal_emit(:response, Gtk::Dialog::RESPONSE_OK) + false + end + dialog.vbox.closeup(radio) + group || radio + end + dialog.ssc(:response) do |w, response_id| + if response_id == Gtk::Dialog::RESPONSE_OK and selected_intent + Plugin::Intent::IntentToken.open( + uri: uri, + model: model, + intent: selected_intent, + parent: nil) + end + w.destroy + end + dialog.show_all + end +end diff --git a/core/plugin/message_detail_view/.mikutter.yml b/core/plugin/message_detail_view/.mikutter.yml new file mode 100644 index 00000000..8ee522e4 --- /dev/null +++ b/core/plugin/message_detail_view/.mikutter.yml @@ -0,0 +1,12 @@ +--- +slug: :message_detail_view +depends: + mikutter: "3.5" + plugin: + - gui + - gtk + - intent +version: '1.0' +author: toshi_a +name: ツイート詳細 +description: 単一のツイートの詳細な内容を表示するタブを追加する diff --git a/core/plugin/message_detail_view/message_detail_view.rb b/core/plugin/message_detail_view/message_detail_view.rb index 0945aae4..cad6e296 100644 --- a/core/plugin/message_detail_view/message_detail_view.rb +++ b/core/plugin/message_detail_view/message_detail_view.rb @@ -1,25 +1,31 @@ # -*- coding: utf-8 -*- -require_relative 'header_widget' +miquire :mui, 'retriever_header_widget' Plugin.create(:message_detail_view) do + intent Message, label: _('ツイートの詳細') do |intent_token| + show_message(intent_token.model) + end + command(:message_detail_view_show, name: '詳細', - condition: lambda{ |opt| opt.messages.size == 1 }, + condition: lambda{ |opt| opt.messages.size == 1 && opt.messages.first.is_a?(Message) }, visible: true, role: :timeline) do |opt| - Plugin.call(:show_message, opt.messages.first) + Plugin.call(:open, opt.messages.first) end + # 互換性のため。 + # openイベントを使おう on_show_message do |message| - show_message(message) + Plugin.call(:open, message) end def show_message(message, force=false) - slug = "message_detail_view-#{message.id}".to_sym + slug = "message_detail_view-#{message.uri}".to_sym if !force and Plugin::GUI::Tab.exist?(slug) Plugin::GUI::Tab.instance(slug).active! else - container = Plugin::MessageInspector::HeaderWidget.new(message) + container = Gtk::RetrieverHeaderWidget.new(message) i_cluster = tab slug, _("詳細タブ") do set_icon Skin.get('message.png') set_deletable true @@ -34,7 +40,10 @@ Plugin.create(:message_detail_view) do tabs.map(&:last).each(&:call) }.next { if !force - i_cluster.active! end }.terminate(_('詳細表示中にエラーが発生しました')) + i_cluster.active! end + }.trap{ |exc| + error exc + } end end diff --git a/core/plugin/message_favorite/message_favorite.rb b/core/plugin/message_favorite/message_favorite.rb index 2ca8a8b0..be024c6c 100644 --- a/core/plugin/message_favorite/message_favorite.rb +++ b/core/plugin/message_favorite/message_favorite.rb @@ -5,7 +5,7 @@ Plugin.create :message_favorite do set_icon Skin.get('unfav.png') user_list = Gtk::UserList.new begin - user_list.add_user Users.new(retriever.favorited_by.to_a) + user_list.add_user retriever.favorited_by rescue => err error err end @@ -13,7 +13,7 @@ Plugin.create :message_favorite do on_favorite do |service, user, to_message| if to_message == message - user_list.add_user(Users.new([user])) end end + user_list.add_user([user]) end end end end diff --git a/core/plugin/message_retweet/message_retweet.rb b/core/plugin/message_retweet/message_retweet.rb index 9da84594..89f79011 100644 --- a/core/plugin/message_retweet/message_retweet.rb +++ b/core/plugin/message_retweet/message_retweet.rb @@ -7,7 +7,7 @@ Plugin.create :message_retweet do set_icon Skin.get('retweet.png') user_list = Gtk::UserList.new begin - user_list.add_user Users.new(retriever.retweeted_by.to_a) + user_list.add_user retriever.retweeted_by rescue => err error err end @@ -17,7 +17,7 @@ Plugin.create :message_retweet do retweets.deach do |retweet| break if user_list.destroyed? if retweet.retweet_source(true) == message - user_list.add_user(Users.new([retweet.user])) end end end + user_list.add_user([retweet.user]) end end end user_list.ssc_atonce :expose_event do Service.primary.retweeted_users(id: message.id).next{|users| diff --git a/core/plugin/openimg/.mikutter.yml b/core/plugin/openimg/.mikutter.yml index 599ffdc1..b4835f46 100644 --- a/core/plugin/openimg/.mikutter.yml +++ b/core/plugin/openimg/.mikutter.yml @@ -1,11 +1,13 @@ --- slug: :openimg depends: - mikutter: '3.2' + mikutter: '3.5' plugin: - gtk - uitranslator -version: '2.0' + - intent +version: '2.1' author: toshi_a -name: 画像プレビュー -description: メジャーな画像投稿サービスのURLがクリックされた時に、画像だけを専用のウィンドウに表示する +name: 画像ビューア +description: >- + 画像をmikutter上で表示する diff --git a/core/plugin/openimg/model/photo.rb b/core/plugin/openimg/model/photo.rb new file mode 100644 index 00000000..69c1ad43 --- /dev/null +++ b/core/plugin/openimg/model/photo.rb @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- + +module Plugin::Openimg + class Photo < Retriever::Model + register :openimg_photo, name: Plugin[:openimg]._('画像ビューア') + + field.uri :perma_link + field.string :blob + + handle ->uri{ + uri_str = uri.to_s + openers = Plugin.filtering(:openimg_image_openers, Set.new).first + openers.any?{ |opener| opener.condition === uri_str } if !openers.empty? + } do |uri| + new(perma_link: uri) + end + + # 画像をダウンロードする。 + # partialを指定すると、ダウンロードの進捗があれば、前回呼び出されたときから + # ダウンロードできた内容を引数に呼び出される。 + # 既にダウンロードが終わっていれば、 _blob_ の戻り値がそのまま渡される。 + # このメソッドは、複数回呼び出されても画像のダウンロードを一度しか行わない。 + # ==== Args + # [&partial_callback] 現在ダウンロードできたデータの一部(String) + # ==== Return + # [Delayer::Deferred::Deferredable] ダウンロードが完了したらselfを引数に呼び出される + def download(&partial_callback) # :yield: part + case @state + when :complete + partial_callback.(blob) if block_given? + Delayer::Deferred.new.next{ self } + when :download + append_download_queue(&partial_callback) + else + download!(&partial_callback) + end + end + + # 画像のダウンロードが終わっていれば真を返す。 + # 真を返す時、 _blob_ には完全な画像の情報が存在している + def completed? + @state == :complete + end + + # 画像をダウロード中なら真 + def downloading? + @state == :download + end + + # ダウンロードが始まっていなければ真 + def ready? + !@state + end + + private + + def download!(&partial_callback) + atomic do + return download(&partial_callback) unless ready? + promise = initialize_download(&partial_callback) + Thread.new(&gen_download_routine).next{|success| + if success + finalize_download_as_success + else + Delayer::Deferred.fail false + end + }.trap{|exception| + finalize_download_as_fail(exception) + }.terminate('error') + promise + end + end + + def append_download_queue(&partial_callback) + atomic do + return download(&partial_callback) unless downloading? + register_partial_callback(partial_callback) + register_promise + end + end + + def register_promise + promise = Delayer::Deferred.new(true) + (@promises ||= Set.new) << promise + promise + end + + def register_partial_callback(cb) + @partials ||= Set.new + if cb + @partials << cb + cb.(@buffer) if !@buffer.empty? + end + end + + def gen_download_routine + -> do + begin + _, raw = Plugin.filtering(:openimg_raw_image_from_display_url, perma_link.to_s, nil) + if raw + download_mainloop(raw) + else + raise "couldn't resolve actual image url of #{perma_link}." + end + rescue EOFError + true + ensure + raw.close rescue nil + end + end + end + + def download_mainloop(raw) + loop do + Thread.pass + partial = raw.readpartial(1024**2).freeze + @buffer << partial + atomic{ @partials.each{|c|c.(partial)} } + end + end + + def initialize_download(&partial_callback) + @state = :download + @buffer = String.new + register_partial_callback(partial_callback) + register_promise + end + + def finalize_download_as_success + atomic do + self.blob = @buffer.freeze + @state = :complete + @promises.each{|p| p.call(self) } + @buffer = @promises = @partials = nil + end + end + + def finalize_download_as_fail(exception) + atomic do + @state = nil + @promises.each{|p| p.fail(exception) } + @buffer = @promises = @partials = nil + end + end + end +end diff --git a/core/plugin/openimg/openimg.rb b/core/plugin/openimg/openimg.rb index 0e81c8d8..d29d6286 100644 --- a/core/plugin/openimg/openimg.rb +++ b/core/plugin/openimg/openimg.rb @@ -2,6 +2,8 @@ require 'gtk2' require 'cairo' +require_relative 'window' +require_relative 'model/photo' module Plugin::Openimg ImageOpener = Struct.new(:name, :condition, :open) @@ -30,7 +32,6 @@ Plugin.create :openimg do prototype: [String, Message] defdsl :defimageopener do |name, condition, &proc| - type_strict condition => :===, name => String opener = Plugin::Openimg::ImageOpener.new(name.freeze, condition, proc).freeze filter_openimg_image_openers do |openers| openers << opener @@ -43,30 +44,10 @@ Plugin.create :openimg do error _ nil end end - filter_openimg_pixbuf_from_display_url do |display_url, loader, thread| - raw = Plugin.filtering(:openimg_raw_image_from_display_url, display_url, nil).last - if raw - begin - loader = Gdk::PixbufLoader.new - thread = Thread.new do - begin - loop do - Thread.pass - partial = raw.readpartial(1024*HYDE) - atomic{ loader.write partial } - end - nil - rescue EOFError - true - ensure - raw.close rescue nil - loader.close rescue nil end end - [display_url, loader, thread] - rescue => _ - error _ - [display_url, loader, thread] end - else - [display_url, loader, thread] end end + filter_openimg_pixbuf_from_display_url do |photo, loader, thread| + loader = Gdk::PixbufLoader.new + [photo, loader, photo.download{|partial| Delayer.new{ loader.write partial } }] + end filter_openimg_raw_image_from_display_url do |display_url, content| unless content @@ -79,134 +60,13 @@ Plugin.create :openimg do [display_url, content] end on_openimg_open do |display_url| - image_surface = loading_surface + Plugin.call(:open, display_url) + end - window = ::Gtk::Window.new(). - set_title(display_url). - set_role('mikutter_image_preview'.freeze). - set_type_hint(Gdk::Window::TYPE_HINT_DIALOG). - set_default_size(*default_size) - w_wrap = ::Gtk::DrawingArea.new - w_toolbar = ::Gtk::Toolbar.new - w_browser = ::Gtk::ToolButton.new(Gtk::Image.new(GdkPixbuf::Pixbuf.new(file: Skin.get('forward.png'), width: 24, height: 24))) - - window.ssc(:destroy, &:destroy) - last_size = nil - w_wrap.ssc(:size_allocate) do - if w_wrap.window && last_size != w_wrap.window.geometry[2,2] - last_size = w_wrap.window.geometry[2,2] - redraw(w_wrap, image_surface) end - false end - w_wrap.ssc(:expose_event) do - redraw(w_wrap, image_surface) - true end - w_browser.ssc(:clicked) do - Gtk.openurl(display_url) - false end - - w_toolbar.insert(0, w_browser) - window.add(Gtk::VBox.new.closeup(w_toolbar).add(w_wrap)) - Thread.new { - Plugin.filtering(:openimg_pixbuf_from_display_url, display_url, nil, nil) - }.next { |result| - if result[1].is_a? Gdk::PixbufLoader - _, pixbufloader, thread = result - pixbufloader.ssc(:area_updated, window) do |_, x, y, width, height| - Delayer.new do - if thread.alive? - image_surface = progress(w_wrap, pixbufloader.pixbuf, image_surface, x: x, y: y, width: width, height: height) end end - true end - - pixbufloader.ssc(:closed, window) do - image_surface = progress(w_wrap, pixbufloader.pixbuf, image_surface, paint: true) - true end - - thread.next { |flag| - Deferred.fail flag unless flag - }.trap { |exception| - error exception - image_surface = error_surface - } - else - warn "cant open: #{display_url}" - image_surface = error_surface - redraw(w_wrap, image_surface) end - }.trap{ |exception| - error exception - image_surface = error_surface - redraw(w_wrap, image_surface) - } - window.show_all end - - def progress(w_wrap, pixbuf, image_surface, x: 0, y: 0, width: 0, height: 0, paint: false) - return unless pixbuf - context = nil - size_changed = false - unless image_surface.width == pixbuf.width and image_surface.height == pixbuf.height - size_changed = true - image_surface = Cairo::ImageSurface.new(pixbuf.width, pixbuf.height) - context = Cairo::Context.new(image_surface) - context.save do - context.set_source_color(Cairo::Color::BLACK) - context.paint end end - context ||= Cairo::Context.new(image_surface) - context.save do - context.set_source_pixbuf(pixbuf) - if paint - context.paint - else - context.rectangle(x, y, width, height) - context.fill end end - redraw(w_wrap, image_surface, repaint: paint || size_changed) - image_surface end - - def default_size - @size || [640, 480] end - - def changesize(w_wrap, window, url) - w_wrap.remove(w_wrap.children.first) - @size = window.window.geometry[2,2].freeze - w_wrap.add(::Gtk::WebIcon.new(url, *@size).show_all) - @size end - - def redraw(w_wrap, image_surface, repaint: true) - gdk_window = w_wrap.window - return unless gdk_window - ew, eh = gdk_window.geometry[2,2] - return if(ew == 0 or eh == 0) - context = gdk_window.create_cairo_context - context.save do - if repaint - context.set_source_color(Cairo::Color::BLACK) - context.paint end - if (ew * image_surface.height) > (eh * image_surface.width) - rate = eh.to_f / image_surface.height - context.translate((ew - image_surface.width*rate)/2, 0) - else - rate = ew.to_f / image_surface.width - context.translate(0, (eh - image_surface.height*rate)/2) end - context.scale(rate, rate) - context.set_source(Cairo::SurfacePattern.new(image_surface)) - context.paint end - rescue => _ - error _ end - - ::Gtk::TimeLine.addopenway(->_{ - openers = Plugin.filtering(:openimg_image_openers, Set.new).first - openers.any?{ |opener| opener.condition === _ } - }) do |shrinked_url, cancel| - Thread.new do - url = (Plugin.filtering(:expand_url, [shrinked_url]).first.first rescue shrinked_url) - Plugin.call(:openimg_open, url) end end + intent Plugin::Openimg::Photo do |intent_token| + Plugin::Openimg::Window.new(intent_token.model, intent_token).start_loading.show_all + end def addsupport(cond, element_rule = {}, &block); end - def loading_surface - surface = Cairo::ImageSurface.from_png(Skin.get('loading.png')) - surface end - - def error_surface - surface = Cairo::ImageSurface.from_png(Skin.get('notfound.png')) - surface end - end diff --git a/core/plugin/openimg/window.rb b/core/plugin/openimg/window.rb new file mode 100644 index 00000000..ee57f07a --- /dev/null +++ b/core/plugin/openimg/window.rb @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- + +module Plugin::Openimg + class Window < Gtk::Window + attr_reader :photo + + def initialize(photo, next_opener) + super() + @photo = photo + @image_surface = loading_surface + @next_opener = next_opener + window_settings + ssc(:destroy, &:destroy) + end + + def start_loading + Thread.new { + Plugin.filtering(:openimg_pixbuf_from_display_url, photo, nil, nil) + }.next { |_, pixbufloader, complete_promise| + if pixbufloader.is_a? Gdk::PixbufLoader + rect = nil + pixbufloader.ssc(:area_updated, self) do |_, x, y, width, height| + if rect + rect[:left] = [rect[:left], x].min + rect[:top] = [rect[:top], y].min + rect[:right] = [rect[:right], x+width].max + rect[:bottom] = [rect[:bottom], y+height].max + else + rect = {left: x, top: y, right: x+width, bottom: y+height} + Delayer.new do + progress(pixbufloader.pixbuf, + x: rect[:left], + y: rect[:top], + width: rect[:right] - rect[:left], + height: rect[:bottom] - rect[:top]) + rect = nil + end + end + true end + + complete_promise.next{ + progress(pixbufloader.pixbuf, paint: true) + pixbufloader.close + }.trap { |exception| + error exception + @image_surface = error_surface + redraw(repaint: true) + } + else + warn "cant open: #{photo}" + @image_surface = error_surface + redraw(repaint: true) end + }.trap{ |exception| + error exception + @image_surface = error_surface + redraw(repaint: true) + } + self + end + + private + + def window_settings + set_title(photo.perma_link.to_s) + set_role('mikutter_image_preview'.freeze) + set_type_hint(Gdk::Window::TYPE_HINT_DIALOG) + set_default_size(*default_size) + add(Gtk::VBox.new.closeup(w_toolbar).add(w_wrap)) + end + + def redraw(repaint: true) + return if w_wrap.destroyed? + gdk_window = w_wrap.window + return unless gdk_window + ew, eh = gdk_window.geometry[2,2] + return if(ew == 0 or eh == 0) + context = gdk_window.create_cairo_context + context.save do + if repaint + context.set_source_color(Cairo::Color::BLACK) + context.paint end + if (ew * @image_surface.height) > (eh * @image_surface.width) + rate = eh.to_f / @image_surface.height + context.translate((ew - @image_surface.width*rate)/2, 0) + else + rate = ew.to_f / @image_surface.width + context.translate(0, (eh - @image_surface.height*rate)/2) end + context.scale(rate, rate) + context.set_source(Cairo::SurfacePattern.new(@image_surface)) + context.paint end + rescue => _ + error _ end + + def progress(pixbuf, x: 0, y: 0, width: 0, height: 0, paint: false) + return unless pixbuf + context = nil + size_changed = false + unless @image_surface.width == pixbuf.width and @image_surface.height == pixbuf.height + size_changed = true + @image_surface = Cairo::ImageSurface.new(pixbuf.width, pixbuf.height) + context = Cairo::Context.new(@image_surface) + context.save do + context.set_source_color(Cairo::Color::BLACK) + context.paint end end + context ||= Cairo::Context.new(@image_surface) + context.save do + context.set_source_pixbuf(pixbuf) + if paint + context.paint + else + context.rectangle(x, y, width, height) + context.fill end end + redraw(repaint: paint || size_changed) + end + + # + # === Widgetたち + # + + def w_wrap + @w_wrap ||= ::Gtk::DrawingArea.new.tap{|w| + w.ssc(:size_allocate, &gen_wrap_size_allocate) + w.ssc(:expose_event, &gen_wrap_expose_event) + } + end + + def w_toolbar + @w_toolbar ||= ::Gtk::Toolbar.new.tap{|w| w.insert(0, w_browser) } + end + + def w_browser + @w_browser ||= ::Gtk::ToolButton.new( + Gtk::Image.new(GdkPixbuf::Pixbuf.new(file: Skin.get('forward.png'), width: 24, height: 24)) + ).tap{|w| + w.ssc(:clicked, &gen_browser_clicked) + } + end + + # + # === イベントハンドラ + # + + def gen_browser_clicked + proc do + Plugin.call(:open, @next_opener) + false + end + end + + def gen_wrap_expose_event + proc do |widget| + redraw(repaint: true) + true + end + end + + def gen_wrap_size_allocate + last_size = nil + proc do |widget| + if widget.window && last_size != widget.window.geometry[2,2] + last_size = widget.window.geometry[2,2] + redraw(repaint: true) + end + false + end + end + + # + # === その他 + # + + def default_size + @size || [640, 480] + end + + def loading_surface + surface = Cairo::ImageSurface.from_png(Skin.get('loading.png')) + surface + end + + def error_surface + surface = Cairo::ImageSurface.from_png(Skin.get('notfound.png')) + surface + end + + end +end diff --git a/core/plugin/quoted_message/quoted_message.rb b/core/plugin/quoted_message/quoted_message.rb index e04e9eff..57446e84 100644 --- a/core/plugin/quoted_message/quoted_message.rb +++ b/core/plugin/quoted_message/quoted_message.rb @@ -13,7 +13,7 @@ Plugin.create :quoted_message do command(:copy_tweet_url, name: _('ツイートのURLをコピー'.freeze), condition: Proc.new{ |opt| - not opt.messages.any?(&:system?)}, + opt.messages.all?(&:perma_link)}, visible: true, role: :timeline) do |opt| Gtk::Clipboard.copy(opt.messages.map(&:perma_link).join("\n".freeze)) @@ -23,7 +23,7 @@ Plugin.create :quoted_message do name: _('コメント付きリツイート'.freeze), icon: MUI::Skin.get('quote.png'), condition: Proc.new{ |opt| - not opt.messages.any?(&:system?)}, + opt.messages.all?(&:perma_link)}, visible: true, role: :timeline) do |opt| messages = opt.messages diff --git a/core/plugin/saved_search/saved_search.rb b/core/plugin/saved_search/saved_search.rb index f0c7e930..ae87f7d3 100644 --- a/core/plugin/saved_search/saved_search.rb +++ b/core/plugin/saved_search/saved_search.rb @@ -73,7 +73,7 @@ Plugin.create :saved_search do saved_search.service.search(q: saved_search.query, count: 100).next{ |res| timeline(saved_search.slug) << res if res.is_a? Array }.trap{ |e| - timeline(saved_search.slug) << Message.new(message: _("更新中にエラーが発生しました (%{error})") % {error: e.to_s}, system: true) } end + timeline(saved_search.slug) << Mikutter::System::Message.new(description: _("更新中にエラーが発生しました (%{error})") % {error: e.to_s}) } end # 全 Service について saved search を取得する # ==== Args diff --git a/core/plugin/search/model/search.rb b/core/plugin/search/model/search.rb new file mode 100644 index 00000000..3158bdd7 --- /dev/null +++ b/core/plugin/search/model/search.rb @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +module Plugin::Search + class Search < Retriever::Model + register :twitter_search, name: Plugin[:search]._('Twitter検索') + + field.string :query, required: true + + # https://twitter.com/search?q=%23superfuckjp + handle ->uri{ + uri.scheme == 'https' && + uri.host == 'twitter.com' && + uri.path == '/search' && + uri.query.split('&').any?{|r|r.split('=', 2).first == 'q'} + } do |uri| + _, query = uri.query.split('&').lazy.map{|r| r.split('=', 2) }.find{|k,v| k == 'q' } + new(query: CGI.unescape(query)) + end + + memoize def perma_link + URI.parse("https://twitter.com/search?q=#{CGI.escape(self.query)}") + end + end +end diff --git a/core/plugin/search/search.rb b/core/plugin/search/search.rb index afb912e6..a59ccb26 100644 --- a/core/plugin/search/search.rb +++ b/core/plugin/search/search.rb @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- +require_relative 'model/search' Plugin.create :search do + intent Plugin::Search::Search do |token| + Plugin.call(:search_start, token.model.query) + end + querybox = ::Gtk::Entry.new() querycont = ::Gtk::VBox.new(false, 0) searchbtn = ::Gtk::Button.new(_('検索')) @@ -37,7 +42,7 @@ Plugin.create :search do elm.sensitive = querybox.sensitive = true }.trap{ |e| error e - timeline(:search) << Message.new(message: _("検索中にエラーが発生しました (%{error})" % {error: e.to_s}), system: true) + timeline(:search) << Mikutter::System::Message.new(description: _("検索中にエラーが発生しました (%{error})" % {error: e.to_s})) elm.sensitive = querybox.sensitive = true } } savebtn.signal_connect('clicked'){ |elm| @@ -46,9 +51,6 @@ Plugin.create :search do Plugin.call(:saved_search_register, saved_search[:id], query, Service.primary) }.terminate(_("検索キーワード「%{query}」を保存できませんでした。あとで試してみてください" % {query: query})) } - Message::Entity.addlinkrule(:hashtags, /(?:#|#)[a-zA-Z0-9_]+/, :search_hashtag){ |segment| - Plugin.call(:search_start, '#' + segment[:url].match(/\A(?:#|#)?(.+)\Z/)[1]) - } end diff --git a/core/plugin/set_view/set_view.rb b/core/plugin/set_view/set_view.rb index 0f5928f1..eb88fc2e 100644 --- a/core/plugin/set_view/set_view.rb +++ b/core/plugin/set_view/set_view.rb @@ -6,16 +6,15 @@ Plugin::create(:set_view) do filter_message_background_color do |miracle_painter, color| if !color + slug = miracle_painter.message.class.slug color = if miracle_painter.selected - UserConfig[:mumble_selected_bg] - elsif(miracle_painter.message.system?) - UserConfig[:mumble_system_bg] + UserConfig[:"#{slug}_selected_bg"] || UserConfig[:mumble_selected_bg] elsif(miracle_painter.message.from_me?) - UserConfig[:mumble_self_bg] + UserConfig[:"#{slug}_self_bg"] || UserConfig[:mumble_self_bg] elsif(miracle_painter.message.to_me?) - UserConfig[:mumble_reply_bg] + UserConfig[:"#{slug}_reply_bg"] || UserConfig[:mumble_reply_bg] else - UserConfig[:mumble_basic_bg] end end + UserConfig[:"#{slug}_basic_bg"] || UserConfig[:mumble_basic_bg] end end [miracle_painter, color] end @@ -26,57 +25,54 @@ Plugin::create(:set_view) do [message, color || UserConfig[:quote_background_color]] end filter_message_font do |message, font| - [message, font || UserConfig[:mumble_basic_font]] end + [message, font || UserConfig[:"#{message.class.slug}_basic_font"] || UserConfig[:mumble_basic_font]] end filter_message_font_color do |message, color| - [message, color || UserConfig[:mumble_basic_color]] end + [message, color || UserConfig[:"#{message.class.slug}_basic_color"] || UserConfig[:mumble_basic_color]] end filter_message_header_left_font do |message, font| - [message, font || UserConfig[:mumble_basic_left_font]] end + [message, font || UserConfig[:"#{message.class.slug}_basic_left_font"] || UserConfig[:mumble_basic_left_font]] end filter_message_header_left_font_color do |message, color| - [message, color || UserConfig[:mumble_basic_left_color]] end + [message, color || UserConfig[:"#{message.class.slug}_basic_left_color"] || UserConfig[:mumble_basic_left_color]] end filter_message_header_right_font do |message, font| - [message, font || UserConfig[:mumble_basic_right_font]] end + [message, font || UserConfig[:"#{message.class.slug}_basic_right_font"] || UserConfig[:mumble_basic_right_font]] end filter_message_header_right_font_color do |message, color| - [message, color || UserConfig[:mumble_basic_right_color]] end + [message, color || UserConfig[:"#{message.class.slug}_basic_right_color"] || UserConfig[:mumble_basic_right_color]] end settings(_("表示")) do - settings(_('つぶやき')) do - settings(_('通常時')) do - settings(_('フォント')) do - fontcolor _('デフォルト'), :mumble_basic_font, :mumble_basic_color - fontcolor _('ヘッダ(左)'), :mumble_basic_left_font, :mumble_basic_left_color - fontcolor _('ヘッダ(右)'), :mumble_basic_right_font, :mumble_basic_right_color + settings _('選択中') do + color _('背景色'), :mumble_selected_bg + end + Plugin.filtering(:retrievers, []).first.each do |modelspec| + slug = modelspec[:slug] + settings(_(modelspec[:name])) do + settings(_('デフォルト')) do + settings(_('フォント')) do + fontcolor _('本文'), [:"#{slug}_basic_font", :mumble_basic_font], [:"#{slug}_basic_color", :mumble_basic_color] + fontcolor _('ヘッダ(左)'), [:"#{slug}_basic_left_font", :mumble_basic_left_font], [:"#{slug}_basic_left_color", :mumble_basic_left_color] + fontcolor _('ヘッダ(右)'), [:"#{slug}_basic_right_font", :mumble_basic_right_font], [:"#{slug}_basic_right_color", :mumble_basic_right_color] + end + color _('背景色'), [:"#{slug}_basic_bg", :mumble_basic_bg] end - color _('背景色'), :mumble_basic_bg - end - settings(_('自分宛')) do - color _('背景色'), :mumble_reply_bg - end - - settings(_('自分のつぶやき')) do - color _('背景色'), :mumble_self_bg - end - - settings(_('システムメッセージ')) do - color _('背景色'), :mumble_system_bg - end + if modelspec[:reply] + settings(_('自分宛の%{retriever}') % {retriever: modelspec[:name]}) do + color _('背景色'), [:"#{slug}_reply_bg", :mumble_reply_bg] + end + end - settings(_('選択中')) do - color _('背景色'), :mumble_selected_bg + if modelspec[:myself] + settings(_('自分の%{retriever}') % {retriever: modelspec[:name]}) do + color _('背景色'), [:"#{slug}_self_bg", :mumble_self_bg] + end + end end end settings(_('背景色')) do - color _('つぶやき'), :mumble_basic_bg - color _('自分宛'), :mumble_reply_bg - color _('自分のつぶやき'), :mumble_self_bg - color _('システムメッセージ'), :mumble_system_bg - color _('選択中'), :mumble_selected_bg color(_('コメント付きリツイート'), :quote_background_color). tooltip(_('コメント付きリツイートをすると、下に囲われて表示されるじゃないですか、あれです')) end diff --git a/core/plugin/settings/listener.rb b/core/plugin/settings/listener.rb index 679f31a9..376bfbc1 100644 --- a/core/plugin/settings/listener.rb +++ b/core/plugin/settings/listener.rb @@ -3,8 +3,11 @@ class Plugin::Settings::Listener def self.[](symbol) return symbol if(symbol.is_a? Plugin::Settings::Listener) - Plugin::Settings::Listener.new( :get => lambda{ UserConfig[symbol] }, - :set => lambda{ |val| UserConfig[symbol] = val }) end + Plugin::Settings::Listener.new( get: lambda{ + key = Array(symbol).find{|s| UserConfig.include?(s) } + UserConfig[key] if key + }, + set: lambda{ |val| UserConfig[Array(symbol).first] = val }) end # ==== Args # [defaults] diff --git a/core/plugin/user_detail_view/.mikutter.yml b/core/plugin/user_detail_view/.mikutter.yml index 13e75ab0..e1d0e614 100644 --- a/core/plugin/user_detail_view/.mikutter.yml +++ b/core/plugin/user_detail_view/.mikutter.yml @@ -7,6 +7,7 @@ depends: - gtk - command - uitranslator + - intent version: '1.0' author: toshi_a name: プロフィール diff --git a/core/plugin/user_detail_view/user_detail_view.rb b/core/plugin/user_detail_view/user_detail_view.rb index b993d993..a4dcd291 100644 --- a/core/plugin/user_detail_view/user_detail_view.rb +++ b/core/plugin/user_detail_view/user_detail_view.rb @@ -4,22 +4,15 @@ Plugin.create :user_detail_view do UserConfig[:profile_show_tweet_once] ||= 20 UserConfig[:profile_icon_size] ||= 64 UserConfig[:profile_icon_margin] ||= 8 + + intent User, label: _('プロフィール') do |intent_token| + show_profile(intent_token.model) + end + plugin = self def timeline_storage # {slug: user} @timeline_storage ||= {} end - Message::Entity.addlinkrule(:user_mentions, Message::MentionMatcher){ |segment| - idname = segment[:url].match(Message::MentionExactMatcher)[1] - user = User.findbyidname(idname) - if user - Plugin.call(:show_profile, Service.primary, user) - else - Thread.new{ - user = Service.primary.scan(:user_show, - :no_auto_since_id => false, - :screen_name => idname) - Plugin.call(:show_profile, Service.primary, user) if user } end } - Delayer.new do (UserConfig[:profile_opened_tabs] || []).uniq.each do |user_id| retrieve_user(user_id).next{|user| @@ -42,11 +35,13 @@ Plugin.create :user_detail_view do else [messages] end end + # 互換性のため。 + # openイベントを使おう on_show_profile do |service, user| - show_profile(user) end + Plugin.call(:open, user) end def show_profile(user, force=false) - slug = "profile-#{user.id}".to_sym + slug = "profile-#{user.uri}".to_sym if !force and Plugin::GUI::Tab.exist?(slug) Plugin::GUI::Tab.instance(slug).active! else @@ -134,7 +129,7 @@ Plugin.create :user_detail_view do command(:aboutuser, name: lambda { |opt| - if defined? opt.messages.first and opt.messages.first.repliable? + if defined? opt.messages.first and opt.messages.first.user.is_a?(User) u = opt.messages.first.user (_("%{screen_name}(%{name})について") % { screen_name: u[:idname], @@ -145,7 +140,7 @@ Plugin.create :user_detail_view do visible: true, icon: lambda{ |opt| opt && opt.messages.first.user[:profile_image_url] }, role: :timeline) do |opt| - Plugin.call(:show_profile, Service.primary, opt.messages.first.user) end + Plugin.call(:open, opt.messages.first.user) end def mutebutton(user) changer = lambda{ |new, widget| diff --git a/core/plugin/user_filesystem_cache/.mikutter.yml b/core/plugin/user_filesystem_cache/.mikutter.yml deleted file mode 100644 index 0242b79d..00000000 --- a/core/plugin/user_filesystem_cache/.mikutter.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -slug: :user_filesystem_cache -depends: - mikutter: '3.1' - plugin: [] -version: '1.0' -author: toshi_a -name: User Filesystem Cache -description: ユーザ情報をファイルシステムにキャッシュして、できるだけAPIに問い合わせないようにします diff --git a/core/plugin/user_filesystem_cache/Gemfile b/core/plugin/user_filesystem_cache/Gemfile deleted file mode 100644 index 0a3e5669..00000000 --- a/core/plugin/user_filesystem_cache/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source 'https://rubygems.org' - -gem 'moneta', '~> 0.7' diff --git a/core/plugin/user_filesystem_cache/user_filesystem_cache.rb b/core/plugin/user_filesystem_cache/user_filesystem_cache.rb deleted file mode 100644 index 95b38d5c..00000000 --- a/core/plugin/user_filesystem_cache/user_filesystem_cache.rb +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -require 'moneta' - -module Plugin::UserFilesystemCache - class Cache - DEFAULT_EXPIRE = (24 * 60 * 60) * 30 # 30 days - - class << self - attr_accessor :keys - end - - include Retriever::DataSource - - def initialize - @db = ::Moneta.build do - use :Expires, expires: UserConfig[:user_filesystem_cache_expire] || DEFAULT_EXPIRE - use :Transformer, key: :md5, value: :marshal - adapter SpreadFileAdapter, dir: File.join(Environment::CACHE, 'user_filesystem_cache') - end - end - - def findbyid(id) - case id - when Enumerable - id.map{|_|@db[_.to_s]}.compact - when Integer - @db[id.to_s] - else - nil end end - - def store_datum(datum) - @db.store(datum[:id].to_s, datum)# expires: EXPIRE) - end - - class SpreadFileAdapter < Moneta::Adapters::File - def store_path(key) - ::File.join(@dir, key[0], key[1], key) - end - end - - end - User.add_data_retriever Cache.new -end - - -Plugin.create(:user_filesystem_cache) do - -end diff --git a/core/plugin/web/.mikutter.yml b/core/plugin/web/.mikutter.yml new file mode 100644 index 00000000..cedf2b25 --- /dev/null +++ b/core/plugin/web/.mikutter.yml @@ -0,0 +1,12 @@ +--- +slug: :web +depends: + mikutter: '3.5' + plugin: + - uitranslator +version: '1.0' +author: toshi_a +name: Web +description: >- + Webページを扱う機能を提供する。 + http,httpsリンクを外部ブラウザを起動することができる diff --git a/core/plugin/web/model/web.rb b/core/plugin/web/model/web.rb new file mode 100644 index 00000000..dd7bb889 --- /dev/null +++ b/core/plugin/web/model/web.rb @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +=begin rdoc +Web上のリソースを示す汎用的なModel。 +これ自体が特別な機能は提供せず、単にURLがWeb上のリソースを指し示していることを表わすために使う。 + +例えば、URLはWebブラウザで開くことができるが、intentは最終的に全てModelに変換できなければならないため、Modelが用意されていない多くのURLは取り扱うことができない。 +=end +module Plugin::Web + class Web < Retriever::Model + register :web + + field.uri :perma_link + + handle ->uri{ %w<http https>.include?(uri.scheme) } do |uri| + new(perma_link: uri) + end + end +end diff --git a/core/plugin/web/web.rb b/core/plugin/web/web.rb new file mode 100644 index 00000000..a22da33e --- /dev/null +++ b/core/plugin/web/web.rb @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +require_relative 'model/web' + +Plugin.create(:web) do + intent Plugin::Web::Web, label: _('外部ブラウザで開く') do |intent_token| + openurl(intent_token.model.perma_link.to_s) + end + + def openurl(url) + if UserConfig[:url_open_specified_command] + url_open_command = UserConfig[:url_open_command] + bg_system(url_open_command, url) + elsif(defined? Win32API) then + shellExecuteA = Win32API.new('shell32.dll','ShellExecuteA',%w(p p p p p i),'i') + shellExecuteA.call(0, 'open', url, 0, 0, 1) + else + url_open_command = find_url_open_command + if url_open_command + bg_system(url_open_command, url) + else + activity :system, _("この環境で、URLを開くためのコマンドが判別できませんでした。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。") end end + rescue => exception + title = _('コマンド "%{command}" でURLを開こうとしましたが、開けませんでした。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。') % {command: url_open_command} + description = { + title: title, + message: exception.to_s, + backtrace: exception.backtrace.join("\n") } + activity :system, title, + error: exception, + description: "%{title}\n\n%{message}\n\n%{backtrace}" % description + end + + # URLを開くことができるコマンドを返す。 + memoize def find_url_open_command + openable_commands = %w{xdg-open open /etc/alternatives/x-www-browser} + wellknown_browsers = %w{firefox chromium opera} + command = nil + catch(:urlopen) do + openable_commands.each{ |o| + if command_exist?(o) + command = o + throw :urlopen end } + wellknown_browsers.each{ |o| + if command_exist?(o) + activity :system, _('この環境で、URLを開くためのコマンドが判別できなかったので、"%{command}"を使用します。設定の「表示→URLを開く方法」で、URLを開く方法を設定してください。') % {command: command} + command = o + throw :urlopen end } end + command end +end diff --git a/core/retriever.rb b/core/retriever.rb index 1d9224b9..538a35ef 100644 --- a/core/retriever.rb +++ b/core/retriever.rb @@ -7,413 +7,4 @@ # ハッシュテーブルを保存し、後から検索できるようにする miquire :lib, 'weakstorage' - -module Retriever - - # モデルクラス。 - # と同時に、このクラスのインスタンスはレコードを表す - class Model - include Comparable - - def self.inherited(subclass) - subclass.instance_eval do - @storage = WeakStorage.new(Integer, subclass) end end - - def initialize(args) - type_strict args => Hash - @value = args.dup - validate - self.class.store_datum(self) - end - - # 新しいオブジェクトを生成します - # 既にそのカラムのインスタンスが存在すればそちらを返します - # また、引数のハッシュ値はmergeされます。 - def self.generate(args, count=-1) - return args if args.is_a?(self) - return self.findbyid(args, count) if not(args.is_a? Hash) - result = self.findbyid(args[:id], count) - return result.merge(args) if result - self.new(args) - end - - def self.rewind(args) - type_strict args => Hash - result_strict(:merge){ new_ifnecessary(args) }.merge(args) - end - - # まだそのレコードのインスタンスがない場合、それを生成して返します。 - def self.new_ifnecessary(hash) - type_strict hash => tcor(self, Hash) - result_strict(self) do - if hash.is_a?(self) - hash - elsif hash[:id] and hash[:id] != 0 - atomic{ - @storage[hash[:id].to_i] or self.new(hash) } - else - raise ArgumentError.new("incorrect type #{hash.class} #{hash.inspect}") end end end - - # - # インスタンスメソッド - # - - # データをマージする。 - # selfにあってotherにもあるカラムはotherの内容で上書きされる。 - # 上書き後、データはDataSourceに保存される - def merge(other) - @value.update(other.to_hash) - validate - self.class.store_datum(self) - end - - def id - @value[:id] - end - - def eql?(other) - other.is_a?(self.class) and other.id == self.id end - - def hash - self.id.to_i end - - def <=>(other) - if other.is_a?(Retriever) - id - other.id - elsif other.respond_to?(:[]) and other[:id] - id - other[:id] - else - id - other end end - - def ==(other) - if other.is_a?(Retriever) - id == other.id - elsif other.respond_to?(:[]) and other[:id] - id == other[:id] - else - id == other end end - - def to_hash - @value.dup - end - - # カラムの生の内容を返す - def fetch(key) - @value[key.to_sym] end - alias [] fetch - - # 速い順にcount個のRetrieverだけに問い合わせて返す - def get(key, count=1) - result = @value[key.to_sym] - column = self.class.keys.assoc(key.to_sym) - if column and result - type = column[1] - if type.is_a? Symbol - Retriever::cast_func(type).call(result) - elsif not result.is_a?(Model) - result = type.findbyid(result, count) - if result - return @value[key.to_sym] = result end end end - result end - - - # カラムに別の値を格納する。 - # 格納後、データはDataSourceに保存される - def []=(key, value) - @value[key.to_sym] = value - self.class.store_datum(self) - value end - - # カラムと型が違うものがある場合、例外を発生させる。 - def validate - raise RuntimeError, "argument is #{@value}, not Hash" if not @value.is_a?(Hash) - self.class.keys.each{ |column| - key, type, required = *column - begin - Model.cast(self.fetch(key), type, required) - rescue InvalidTypeError=>e - estr = e.to_s + "\nin #{self.fetch(key).inspect} of #{key}" - warn estr - warn @value.inspect - raise InvalidTypeError, estr end } end - - # キーとして定義されていない値を全て除外した配列を生成して返す。 - # また、Modelを子に含んでいる場合、それを外部キーに変換する。 - def filtering - datum = self.to_hash - result = Hash.new - self.class.keys.each{ |column| - key, type = *column - begin - result[key] = Model.cast(datum[key], type) - rescue InvalidTypeError=>e - raise InvalidTypeError, e.to_s + "\nin #{datum.inspect} of #{key}" end } - result end - - # - # クラスメソッド - # - - # モデルのキーを定義します。 - # これを継承した実際のモデルから呼び出されることを想定しています - def self.keys=(keys) - @keys = keys end - - def self.keys - @keys end - - # srcが正常にModel化できるかどうかを返します。 - def self.valid?(src) - return src.is_a?(self) if not src.is_a?(Hash) - not self.get_error(src) end - - # srcがModel化できない理由を返します。 - def self.get_error(src) - self.keys.each{ |column| - key, type, required = *column - begin - Model.cast(src[key], type, required) - rescue InvalidTypeError=>e - return e.to_s + "\nin key '#{key}' value '#{src[key]}'" end } - false end - - # DataSourceのチェーンに、 _retriever_ を登録します - def self.add_data_retriever(retriever) - retriever.keys = self.keys - retrievers_add(retriever) - retriever end - - # 特定のIDを持つオブジェクトを各データソースに問い合わせて返します。 - # 何れのデータソースもそれを見つけられなかった場合、nilを返します。 - def self.findbyid(id, count=-1) - return findbyid_ary(id, count) if id.is_a? Array - raise if id.is_a? Model - result = nil - catch(:found){ - rs = self.retrievers - count = rs.length + count + 1 if(count <= -1) - rs = rs.slice(0, [count, 1].max) - rs.each{ |retriever| - detection = retriever.findbyid_timer(id) - if detection - result = detection - throw :found end } } - self.retrievers_reorder - if result.is_a? Retriever::Model - result - elsif result.is_a? Hash - self.new_ifnecessary(result) end - rescue => e - error e - abort end - - def self.findbyid_ary(ids, count=-1) - result = [] - remain = ids.clone - ids.freeze - catch(:found){ - rs = self.retrievers - count = rs.length + count + 1 if count <= -1 - rs.slice(0, [count, 1].max).each{ |retriever| - detection = retriever.findbyid_timer(remain) - if detection - detection = detection.compact.map(&method(:new_ifnecessary)) - result.concat(detection) - remain -= detection.map{ |x| x[:id].to_i } - throw :found if remain.empty? end } } - self.retrievers_reorder - container_class.new(result.sort_by{ |user| ids.index(user[:id].to_i) || 1.0/0 }) end - - def self.selectby(key, value, count=-1) - key = key.to_sym - result = [] - rs = self.retrievers - count = rs.length + count + 1 if(count <= -1) - rs = rs.slice(0, [count, 1].max) - rs.each{ |retriever| - detection = retriever.selectby_timer(key, value) - result += detection if detection } - self.retrievers_reorder - result.uniq.map{ |node| - if node.is_a? Hash - self.new_ifnecessary(node) - elsif node.is_a? Model - node - else - self.findbyid(node) end } end - - # - # プライベートクラスメソッド - # - - # データを一件保存します。 - # 保存は、全てのデータソースに対して行われます - def self.store_datum(datum) - atomic{ - @storage[datum[:id].to_i] = result_strict(self){ datum } } - return datum if datum[:system] - converted = datum.filtering - self.retrievers.each{ |retriever| - retriever.store_datum(converted) } - datum - end - - # 値を、そのカラムの型にキャストします。 - # キャスト出来ない場合はInvalidTypeError例外を投げます - def self.cast(value, type, required=false) - if value.nil? - raise InvalidTypeError, 'it is required value'+[value, type, required].inspect if required - nil - elsif type.is_a?(Symbol) - begin - result = (value and Retriever::cast_func(type).call(value)) - if required and not result - raise InvalidTypeError, 'it is required value, but returned nil from cast function' end - result - rescue InvalidTypeError - raise InvalidTypeError, "#{value.inspect} is not #{type}" end - elsif type.is_a?(Array) - if value.respond_to?(:map) - value.map{|v| cast(v, type.first, required)} - elsif not value - nil - else - raise InvalidTypeError, 'invalid type' end - elsif value.is_a?(type) - raise InvalidTypeError, 'invalid type' if required and not value.id - value.id - elsif self.cast(value, type.keys.assoc(:id)[1], true) - value end end - - # メモリキャッシュオブジェクトを返す - def self.memory_class - Memory end - - def self.container_class - Array end - - # DataSourceの配列を返します。 - def self.retrievers - atomic{ - @retrievers = [memory_class.new(@storage)] if not defined? @retrievers } - @retrievers - end - - def self.retrievers_add(retriever) - self.retrievers << retriever end - - #DataSourceの配列を、最後の取得が早かった順番に並び替えます - def self.retrievers_reorder - atomic{ - @retrievers = self.retrievers.sort_by{ |r| r.time } } - end - - end - - # データの保存/復元を実際に担当するデータソース。 - # データソースをモデルにModel::add_data_retrieverにて幾つでも参加させることが出来る。 - module DataSource - attr_accessor :keys - - # idをもつデータを返す。 - # もし返せない場合は、nilを返す - def findbyid(id) - nil - end - - # 取得できたらそのRetrieverのインスタンスをキーにして実行されるDeferredを返す - def idof(id) - Thread.new{ findbyid(id) } end - alias [] idof - - # keyがvalueのオブジェクトを配列で返す。 - # マッチしない場合は空の配列を返す。Arrayオブジェクト以外は返してはならない。 - def selectby(key, value) - [] - end - - # データの保存 - # データ一件保存する。保存に成功したか否かを返す。 - def store_datum(datum) - false - end - - def findbyid_timer(id) - st = Process.times.utime - result = findbyid(id) - @time = Process.times.utime - st if result - result - end - - def selectby_timer(key, value) - st = Process.times.utime - result = selectby(key, value) - @time = Process.times.utime - st if not result.empty? - result - end - - def time - defined?(@time) ? @time : 0.0 - end - - def inspect - self.class.to_s - end - end - - @@cast = { - :int => lambda{ |v| begin v.to_i; rescue NoMethodError then raise InvalidTypeError end }, - :bool => lambda{ |v| !!(v and not v == 'false') }, - :string => lambda{ |v| begin v.to_s; rescue NoMethodError then raise InvalidTypeError end }, - :time => lambda{ |v| - if not v then - nil - elsif v.is_a? String then - Time.parse(v) - else - Time.at(v) - end - } - } - - def self.cast_func(type) - @@cast[type] - end - - class RetrieverError < StandardError - end - - class InvalidTypeError < RetrieverError - end - - class Model::Memory - include Retriever::DataSource - - def initialize(storage) - @storage = storage end - - # def children - # @children ||= Hash.new{ |h, k| h[k] = Set.new } end - - def findbyid(id) - if id.is_a? Array or id.is_a? Set - id.map{ |i| @storage[i.to_i] } - else - @storage[id.to_i] end - end - - # def selectby(key, value) - # if key == :replyto - # children[value.to_i].to_a - # else - # [] end end - - # データの保存 - # def store_datum(datum) - # @storage[datum[:id]] = datum - # @children[datum[:replyto].to_i].push(datum[:id].to_i) if datum[:replyto] - # true - # end - end - -end +miquire :lib, 'retriever' diff --git a/core/service.rb b/core/service.rb index b26f9a11..28743e4e 100644 --- a/core/service.rb +++ b/core/service.rb @@ -2,7 +2,7 @@ require 'instance_storage' -miquire :core, 'environment', 'user', 'message', 'userlist', 'configloader', 'userconfig', 'service_keeper' +miquire :core, 'environment', 'configloader', 'userconfig', 'service_keeper' miquire :lib, "mikutwitter", 'reserver', 'delayer' =begin rdoc @@ -115,8 +115,6 @@ class Service @twitter.consumer_secret = Environment::TWITTER_CONSUMER_SECRET @twitter.a_token = account[:token] @twitter.a_secret = account[:secret] - Message.add_data_retriever(MessageServiceRetriever.new(self, :status_show)) - User.add_data_retriever(UserServiceRetriever.new(self, :user_show)) user_initialize end @@ -300,37 +298,6 @@ class Service "Twitterサーバの情況を調べたくない→ http://www.nicovideo.jp/vocaloid\n\n--\n\n" + "#{res.code} #{res.body}" end end - class ServiceRetriever - include Retriever::DataSource - - def initialize(post, api) - @post = post - @api = api - end - - def findbyid(id) - if id.is_a? Enumerable - id.map(&method(:findbyid)) - else - @post.scan(@api, :id => id) end end - - def time - 1.0/0 end - end - - class MessageServiceRetriever < ServiceRetriever - end - - class UserServiceRetriever < ServiceRetriever - include Retriever::DataSource - - def findbyid(id) - if id.is_a? Enumerable - id.each_slice(100).map{|id_list| - @post.scan(:user_lookup, id: id_list.join(','.freeze)) || [] }.flatten - else - @post.scan(@api, :id => id) end end end - class Error < RuntimeError; end class NotExistError < Error; end diff --git a/core/system/message.rb b/core/system/message.rb new file mode 100644 index 00000000..10cbe864 --- /dev/null +++ b/core/system/message.rb @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +miquire :core, 'retriever', 'skin' +miquire :lib, 'retriever/mixin/message_mixin' + +class Mikutter::System::Message < Retriever::Model + include Retriever::Model::MessageMixin + + register :system_message, + name: "System Message" + + field.string :description, required: true + field.has :user, Mikutter::System::User, required: true + field.string :created + field.string :modified + + entity_class Retriever::Entity::URLEntity + + def initialize(value) + value[:user] ||= Mikutter::System::User.system + value[:modified] ||= value[:created] ||= Time.now.freeze + super(value) + end + + # 投稿がシステムメッセージだった場合にtrueを返す + def system? + true + end + + def to_me? + true + end + +end diff --git a/core/system/system.rb b/core/system/system.rb new file mode 100644 index 00000000..84332e10 --- /dev/null +++ b/core/system/system.rb @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +module Mikutter + module System; end end + +miquire :system, :user, :message + + + + + + + + + + diff --git a/core/system/user.rb b/core/system/user.rb new file mode 100644 index 00000000..06debecc --- /dev/null +++ b/core/system/user.rb @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +miquire :core, 'retriever', 'skin' +miquire :lib, 'retriever/mixin/user_mixin' + +class Mikutter::System::User < Retriever::Model + include Retriever::Model::UserMixin + field.string :idname + field.string :name + field.string :detail + field.string :profile_image_url + + memoize def self.system + Mikutter::System::User.new(idname: 'mikutter_bot', + name: Environment::NAME, + profile_image_url: Skin.get("icon.png")) + end + + def system? + true end + +end + + + + + + + diff --git a/core/user.rb b/core/user.rb index 7869095b..7d247c82 100644 --- a/core/user.rb +++ b/core/user.rb @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- miquire :core, 'retriever', 'skin' - +miquire :system, 'system' miquire :lib, 'typed-array' class User < Retriever::Model extend Gem::Deprecate + include Retriever::Model::Identity - @@users_id = WeakStorage.new(String, User) # {idname => User} + register :twitter_user, name: "Twitter User" # args format # key | value @@ -19,46 +20,47 @@ class User < Retriever::Model # detail | detail # profile_image_url | icon - self.keys = [[:id, :string], - [:idname, :string], - [:name, :string], - [:location, :string], - [:detail, :string], - [:profile_image_url, :string], - [:url, :string], - [:protected, :bool], - [:verified, :bool], - [:followers_count, :int], - [:statuses_count, :int], - [:friends_count, :int], - ] - - def self.system - if not defined? @@system then - @@system = User.new({ :id => 0, - :idname => 'mikutter_bot', - :name => Environment::NAME, - :profile_image_url => Skin.get("icon.png")}) + field.string :id + field.string :idname + field.string :name + field.string :location + field.string :detail + field.string :profile_image_url + field.string :url + field.bool :protected + field.bool :verified + field.int :followers_count + field.int :statuses_count + field.int :friends_count + + handle %r[\Ahttps?://twitter.com/[a-zA-Z0-9_]+/?\Z] do |uri| + match = %r[\Ahttps?://twitter.com/(?<screen_name>[a-zA-Z0-9_]+)/?\Z].match(uri.to_s) + notice match.inspect + if match + user = findbyidname(match[:screen_name], Retriever::DataSource::USE_LOCAL_ONLY) + if user + user + else + Thread.new do + findbyidname(match[:screen_name], Retriever::DataSource::USE_ALL) + end + end + else + raise Retriever::RetrieverError, "id##{match[:screen_name]} does not exist in #{self}." end - @@system end - def self.memory_class - result = Class.new do - def self.set_users_id(users) - @@users_id = users end + def self.system + Mikutter::System::User.system end - def self.new(storage) - UserMemory.new(storage, @@users_id) end end - result.set_users_id(@@users_id) - result end + def self.memory + @memory ||= UserMemory.new end def self.container_class Users end - def initialize(*args) - super - @@users_id[idname] = self end + alias :to_i :id + deprecate :to_i, "id", 2017, 05 def idname self[:idname] end @@ -90,13 +92,12 @@ class User < Retriever::Model "User(@#{@value[:idname]})" end - def self.findbyidname(idname, count=-1) - if(@@users_id.has_key?(idname)) - @@users_id[idname] - elsif caller(1).include?(caller[0]) - selectby(:idname, idname, count).first - end - end + # 投稿がシステムユーザだった場合にtrueを返す + def system? + false end + + def self.findbyidname(idname, count=Retriever::DataSource::USE_ALL) + memory.findbyidname(idname, count) end def self.store_datum(datum) return datum if datum[:id][0] == '+'[0] @@ -130,6 +131,9 @@ class User < Retriever::Model def count_favorite_given @value[:favourites_count] end + memoize def perma_link + URI.parse("https://twitter.com/#{idname}").freeze end + def user self end alias to_user user @@ -139,16 +143,34 @@ class User < Retriever::Model end class UserMemory < Retriever::Model::Memory - def initialize(storage, idnames) - super(storage) - @idnames = idnames + def initialize + super + @idnames = {} # idname => User end - def selectby(key, value) - if key == :idname and @idnames[value].is_a?(User) - [@idnames[value]] + def findbyid(id, policy) + result = super + if !result and policy == Retriever::DataSource::USE_ALL + if id.is_a? Enumerable + id.each_slice(100).map{|id_list| + Service.primary.scan(:user_lookup, id: id_list.join(','.freeze)) || [] }.flatten + else + Service.primary.scan(:user_show, id: id) end else - [] end end + result end end + + def findbyidname(idname, policy) + if @idnames[idname.to_s] + @idnames[idname.to_s] + elsif policy == Retriever::DataSource::USE_ALL + Service.primary.scan(:user_show, screen_name: idname) + end + end + + def store_datum(retriever) + @idnames[retriever.idname.to_s] = retriever + super + end end end diff --git a/core/userconfig.rb b/core/userconfig.rb index 4328d10d..bbb82ca9 100644 --- a/core/userconfig.rb +++ b/core/userconfig.rb @@ -145,6 +145,17 @@ class UserConfig @@watcher_id = Hash.new @@watcher_id_count = 0 + # キーに対応する値が存在するかを調べる。 + # 値が設定されていれば、それが _nil_ や _false_ であっても _true_ を返す + # ==== Args + # [key] Symbol キー + # ==== Return + # [true] 存在する + # [false] 存在しない + def self.include?(key) + UserConfig.instance.include?(key) || @@defaults.include?(key) + end + # 設定名 _key_ にたいする値を取り出す # 値が設定されていない場合、nilを返す。 def self.[](key) diff --git a/core/userlist.rb b/core/userlist.rb index c8a9e90e..4540ec2a 100644 --- a/core/userlist.rb +++ b/core/userlist.rb @@ -12,7 +12,7 @@ miquire :core, 'user', 'message', 'retriever' require 'set' class UserList < Retriever::Model - @@system_id = 0 + include Retriever::Model::Identity # args format # key | value(class) @@ -24,14 +24,13 @@ class UserList < Retriever::Model # user | user who post this message(User or Hash or mixed(User IDNumber)) # slug | list slug(String) - self.keys = [[:id, :int, true], - [:name, :string, true], - [:mode, :bool], - [:description, :string], - [:user, User, true], - [:slug, :string, true], - [:member, [User]] - ] + field.int :id, required: true + field.string :name, required: true + field.bool :mode + field.string :description + field.has :user, User, required: true + field.string :slug, required: true + field.has :member, [User] def initialize(value) type_strict value => Hash @@ -44,14 +43,14 @@ class UserList < Retriever::Model def user self[:user] end + memoize def perma_link + URI.parse("https://twitter.com/#{user.idname}/lists/#{CGI.escape(slug)}").freeze end + def member self[:member] ||= Set.new end def member?(user) - if user.is_a? User - member.include?(user) - else - member.any?{ |m| m.id == user.to_i } end end + member.include?(user) if user.is_a? User end # リプライだった場合、投稿した人と宛先が一人でもリストメンバーだったら真。 # リプライではない場合は、 UserList.member?(message.user) と同じ @@ -5,7 +5,7 @@ successed = [] failed = [] processes = {} -Dir.glob(File.dirname(__FILE__) + '/test/core/test_*').each{ |f| +Dir.glob(File.dirname(__FILE__) + '/test/core/**/test_*').each{ |f| processes[fork { require File.expand_path(f) }] = f } Process.waitall.each{ |pid, stat| diff --git a/test/core/lib/retriever/entity/test_twitter_entity.rb b/test/core/lib/retriever/entity/test_twitter_entity.rb new file mode 100644 index 00000000..c1822d6f --- /dev/null +++ b/test/core/lib/retriever/entity/test_twitter_entity.rb @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +require_relative '../../../../helper' + +miquire :lib, 'retriever' + +class TC_TwitterEntity < Test::Unit::TestCase + class EntityTestModel < Retriever::Model + field.string :message, required: true + field.time :created + field.time :modified + + entity_class Retriever::Entity::TwitterEntity + + def to_show + self[:message] + end + end + + def setup + end + + # 2つ目以降のEntityの位置がずれる問題 + def test_nested_legacy_retweet + model = EntityTestModel.new( + message: '変な意味じゃなくてね。RT @kouichi_0308: アレでソレですか…(´・ω・`)シュン… RT @nene_loveplus: 昔から一緒にいるフォロワーさんは色々アレでソレでちょっと困っちゃうわね…。', + entities: { + user_mentions: [ { id_str: "127914421", + screen_name: "kouichi_0308", + name: "コウイチ(2011年度版)", + id: 127914421, + indices: [14, 27]}, + { id_str: "95126742", + screen_name: "nene_loveplus", + name: "姉ヶ崎寧々", + id: 95126742, + indices: [53, 67]}], + urls: [], + hashtags: [] }) + assert_equal model[:message], model.entity.to_s + end + + # TwitterがEntityを返さないという勘弁してほしいケース + def test_lost_entity + model = EntityTestModel.new( + message: 'RT @toshi_a: おいmikutterが変態ツイッタークライアントだという風説が', + entities: {}) + assert_equal model[:message], model.entity.to_s + end + + def test_mention_no_space_splitted + model = EntityTestModel.new( + message: 'もともとは@penguin2716さんに勧められて始めたついった。そして彼より遅く始めた自分がTwitterにハマり、彼の総ポスト数をすぐに追い抜いたと思ったのですが、今や彼はれっきとしたmikutter廃人となり、ワタシの手の届かぬ領域に到達されました。', + entities: {}) + assert_equal model[:message], model.entity.to_s + end + + # >のあとに@...があるケース。エンティティエンコードのせいで位置がずれてないか + def test_segment_after_escaped_character + model = EntityTestModel.new( + message: '一体何をやってんだろう(笑)。 > @toshi_a the hacker', + entities: { + hashtags: [], + urls: [], + user_mentions: [ + { name: "蝶舞スカーフ型としぁ", + screen_name: "toshi_a", + indices: [21, 29], + id: 15926668, + id_str: "15926668"}]}) + assert_equal model[:message], model.entity.to_s + end + + # URLのあとにハッシュタグがある + def test_mixed_entities + model = EntityTestModel.new( + message: 'まだまだ絶賛配信中!今日は「日常のラヂオ」第35回がランティスネットラジオ goo.gl/2tsIG にて22時から配信スタートです!「日常」が好きな人ならきっと楽しんでいただけますのでよろしくお願いします。 #nichijou', + entities: { + user_mentions: [], + urls: [ + { url: "goo.gl/2tsIG", + indices: [38, 50], + expanded_url: nil } ], + hashtags: [ + { indices: [105, 114], + text: "nichijou" } ]}) + assert_equal model[:message], model.entity.to_s + end + + # まじでいらん機能によってエンティティの計算がずれていないか + def test_cashtags + model = EntityTestModel.new( + message: "@uebayasi I tried and it works as below:\n$ uname -srm\nNetBSD 6.0.1 amd64\n$ s ( ) { local b; b=1; echo $b; b=\"$@\"; echo $b; }\n$ s d e\n1\nd e", + entities: { + hashtags: [], + symbols: [{ indices: [103, 105], + text: "b"}, + { indices: [120, 122], + text: "b"}], + urls: [], + user_mentions: [{ id: 5864792, + id_str: "5864792", + indices: [0, 9], + name: "Masao Uebayashi", + screen_name: "uebayasi" }]}) + assert_equal model[:message], model.entity.to_s + end + + # TwitterがEntityを返さないという勘弁してほしいケース + def test_has_media + model = EntityTestModel.new( + message: "【特価品】Celeron C1037U&HM77、デュアルLAN(82574L)を搭載したNAS向miniITXマザー Giada N70E-DR V2 14980円 http://t.co/zYaNWyebgm https://t.co/DCbLaYFXeu", + entities: { + hashtags: [], + symbols: [], + urls: + [{ url: "http://t.co/zYaNWyebgm", + expanded_url: "http://goo.gl/HDK5i", + display_url: "goo.gl/HDK5i", + indices: [88, 110]}], + user_mentions: [], + media: + [{ id: 352644565880168448, + id_str: "352644565880168448", + indices: [111, 134], + media_url: "http://pbs.twimg.com/media/BOTYXUFCYAAGmf_.jpg", + media_url_https: "https://pbs.twimg.com/media/BOTYXUFCYAAGmf_.jpg", + url: "https://t.co/DCbLaYFXeu", + display_url: "pic.twitter.com/DCbLaYFXeu", + expanded_url: + "http://twitter.com/ph_toei/status/352644565875974144/photo/1", + type: "photo", + sizes: + { medium: {w: 480, h: 437, resize: "fit"}, + thumb: {w: 150, h: 150, resize: "crop"}, + small: {w: 340, h: 310, resize: "fit"}, + large: {w: 480, h: 437, resize: "fit"}}, + source_status_id: 352644565875974144, + source_status_id_str: "352644565875974144"}]}) + assert_equal "【特価品】Celeron C1037U&HM77、デュアルLAN(82574L)を搭載したNAS向miniITXマザー Giada N70E-DR V2 14980円 goo.gl/HDK5i pic.twitter.com/DCbLaYFXeu", model.entity.to_s + end + + +end diff --git a/test/core/test_entity.rb b/test/core/test_entity.rb deleted file mode 100644 index b1d274e6..00000000 --- a/test/core/test_entity.rb +++ /dev/null @@ -1,217 +0,0 @@ -# -*- coding: utf-8 -*- - -require File.expand_path(File.dirname(__FILE__) + '/../helper') -#require File.expand_path(File.dirname(__FILE__) + '/../utils') -miquire :core, 'entity' -miquire :core, 'userconfig' - -class Plugin -end - -class String - def inspect - to_s - end -end - -module Pango - ESCAPE_RULE = {'&' => '&' ,'>' => '>', '<' => '<'}.freeze - class << self - - # テキストをPango.parse_markupで安全にパースできるようにエスケープする。 - def escape(text) - text.gsub(/[<>&]/){|m| Pango::ESCAPE_RULE[m] } end end end - - -class TC_Message < Test::Unit::TestCase - - THE_TWEET = '変な意味じゃなくてね。RT @kouichi_0308: アレでソレですか…(´・ω・`)シュン… RT @nene_loveplus: 昔から一緒にいるフォロワーさんは色々アレでソレでちょっと困っちゃうわね…。' - THE_ENTITY = { - :user_mentions => [ { :id_str => "127914421", - :screen_name => "kouichi_0308", - :name => "コウイチ(2011年度版)", - :id => 127914421, - :indices => [14, 27]}, - { :id_str => "95126742", - :screen_name => "nene_loveplus", - :name=>"姉ヶ崎寧々", - :id=>95126742, - :indices=>[53, 67]}], - :urls => [], - :hashtags => [] } - - THE_TWEET2 = '♥ Unfaithful by Rihanna #lastfm: http://bit.ly/3YP9Hq amazon: http://bit.ly/1tmPYb' - THE_ENTITY2 = { - :user_mentions=>[], - :hashtags=> [ { :text=>"lastfm", - :indices=>[30, 37] } ], - :urls=> [ { :expanded_url=>nil, - :indices=>[39, 59], - :url=>"http://bit.ly/3YP9Hq" }, - { :expanded_url=>nil, - :indices=>[68, 88], - :url=>"http://bit.ly/1tmPYb" } ] } - - THE_TWEET3 = 'RT @toshi_a: おいmikutterが変態ツイッタークライアントだという風説が' - THE_ENTITY3 = {} - - def setup - Plugin.stubs(:call).returns(true) - Message::Entity.addlinkrule(:urls, URI.regexp(['http','https'])){ |segment| } - Message::Entity.addlinkrule(:media){ |segment| } - Message::Entity.addlinkrule(:hashtags, /(#|#)([a-zA-Z0-9_]+)/){ |segment|} - Message::Entity.addlinkrule(:user_mentions, /(@|@|〄)[a-zA-Z0-9_]+/){ |segment| } - end - - def teardown - Message::Entity.refresh - end - - def test_index_to_escaped_index - assert_equal(5, Message::Entity.index_to_escaped_index("foobar", 5)) - assert_equal(2, Message::Entity.index_to_escaped_index("<foobar>", 5)) - assert_equal(5, Message::Entity.index_to_escaped_index("<foo><bar>", 11)) - assert_equal(5, Message::Entity.index_to_escaped_index("<&>abc", 15)) - end - - def test_1 - mes = stub - mes.stubs(:to_show).returns(THE_TWEET) - mes.stubs(:[]).with(:entities).returns(THE_ENTITY) - mes.stubs(:is_a?).with(Message).returns(true) - - entity = Message::Entity.new(mes) - - assert_equal("変な意味じゃなくてね。RT @kouichi_0308: アレでソレですか…(´・ω・`)シュン… RT @nene_loveplus: 昔から一緒にいるフォロワーさんは色々アレでソレでちょっと困っちゃうわね…。", entity.to_s) - - splited = mes.to_show.split(//u).map{ |s| Pango::ESCAPE_RULE[s] || s } - entity.reverse_each{ |l| - splited[l[:range]] = '<span underline="single" underline_color="#000000">'+"#{Pango.escape(l[:face])}</span>" - } - splited - end - - def test_3 - mes = stub - mes.stubs(:to_show).returns(THE_TWEET3) - mes.stubs(:[]).with(:entities).returns(THE_ENTITY3) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - assert_kind_of(String, entity.to_s) - assert_equal("RT @toshi_a: \343\201\212\343\201\204mikutter\343\201\214\345\244\211\346\205\213\343\203\204\343\202\244\343\203\203\343\202\277\343\203\274\343\202\257\343\203\251\343\202\244\343\202\242\343\203\263\343\203\210\343\201\240\343\201\250\343\201\204\343\201\206\351\242\250\350\252\254\343\201\214", entity.to_s.inspect) - end - - def test_4 - tweet = 'もともとは@penguin2716さんに勧められて始めたついった。そして彼より遅く始めた自分がTwitterにハマり、彼の総ポスト数をすぐに追い抜いたと思ったのですが、今や彼はれっきとしたmikutter廃人となり、ワタシの手の届かぬ領域に到達されました。' - mes = stub - mes.stubs(:to_show).returns(tweet) - mes.stubs(:[]).with(:entities).returns({}) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - - assert_kind_of(String, entity.to_s) - assert_equal(tweet, entity.to_s.inspect) - end - - def test_5 - tweet = '一体何をやってんだろう(笑)。 > @toshi_a the hacker' - mes = stub - mes.stubs(:to_show).returns(tweet) - mes.stubs(:[]).with(:entities).returns({:hashtags=>[], :urls=>[], :user_mentions=>[{:name=>"蝶舞スカーフ型としぁ", :screen_name=>"toshi_a", :indices=>[21, 29], :id=>15926668, :id_str=>"15926668"}]}) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - - assert_kind_of(String, entity.to_s) - assert_equal(tweet, entity.to_s.inspect) - end - - def test_6 - Plugin.stubs(:filtering).with(:is_expanded, 'goo.gl/2tsIG').returns([true]) - tweet = 'まだまだ絶賛配信中!今日は「日常のラヂオ」第35回がランティスネットラジオ goo.gl/2tsIG にて22時から配信スタートです!「日常」が好きな人ならきっと楽しんでいただけますのでよろしくお願いします。 #nichijou' - mes = stub - mes.stubs(:to_show).returns(tweet) - mes.stubs(:[]).with(:entities). - returns({ :user_mentions=>[], - :urls => [{ :url=>"goo.gl/2tsIG", - :indices=>[38, 50], - :expanded_url=>nil } ], - :hashtags => [{ :indices=>[105, 114], - :text=>"nichijou" } ]}) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - - assert_kind_of(String, entity.to_s) - assert_equal(tweet, entity.to_s.inspect) - end - - def test_7 - tweet = "@uebayasi I tried and it works as below:\n$ uname -srm\nNetBSD 6.0.1 amd64\n$ s ( ) { local b; b=1; echo $b; b=\"$@\"; echo $b; }\n$ s d e\n1\nd e" - mes = stub - mes.stubs(:to_show).returns(tweet) - mes.stubs(:[]).with(:id).returns(325862212793159680) - mes.stubs(:[]).with(:message).returns(tweet) - mes.stubs(:[]).with(:entities). - returns({ hashtags: [], - symbols: [{ indices: [103, 105], - text: "b"}, - { indices: [120, 122], - text: "b"}], - urls: [], - user_mentions: [{ id: 5864792, - id_str: "5864792", - indices: [0, 9], - name: "Masao Uebayashi", - screen_name: "uebayasi" }]}) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - - assert_kind_of(String, entity.to_s) - assert_equal(tweet, entity.to_s.inspect) - end - - def test_8 - Plugin.stubs(:filtering).with(:expand_url, 'http://t.co/zYaNWyebgm').returns(["http://goo.gl/HDK5i"]) - Plugin.stubs(:filtering).with(:is_expanded, 'http://t.co/zYaNWyebgm').returns([false]) - Plugin.stubs(:filtering).with(:is_expanded, 'http://goo.gl/HDK5i').returns([true]) - Plugin.stubs(:filtering).with(:expand_url, 'https://t.co/DCbLaYFXeu').returns(["http://twitter.com/ph_toei/status/352644565875974144/photo/1"]) - Plugin.stubs(:filtering).with(:is_expanded, 'https://t.co/DCbLaYFXeu').returns([false]) - mes = stub - mes.stubs(:to_show).returns("【特価品】Celeron C1037U&HM77、デュアルLAN(82574L)を搭載したNAS向miniITXマザー Giada N70E-DR V2 14980円 http://t.co/zYaNWyebgm https://t.co/DCbLaYFXeu") - mes.stubs(:[]).with(:id).returns(429203799404593152) - mes.stubs(:[]).with(:message).returns("【特価品】Celeron C1037U&HM77、デュアルLAN(82574L)を搭載したNAS向miniITXマザー Giada N70E-DR V2 14980円 http://t.co/zYaNWyebgm https://t.co/DCbLaYFXeu") - mes.stubs(:[]).with(:entities). - returns({ hashtags: [], - symbols: [], - urls: - [{ url: "http://t.co/zYaNWyebgm", - expanded_url: "http://goo.gl/HDK5i", - display_url: "goo.gl/HDK5i", - indices: [88, 110]}], - user_mentions: [], - media: - [{ id: 352644565880168448, - id_str: "352644565880168448", - indices: [111, 134], - media_url: "http://pbs.twimg.com/media/BOTYXUFCYAAGmf_.jpg", - media_url_https: "https://pbs.twimg.com/media/BOTYXUFCYAAGmf_.jpg", - url: "https://t.co/DCbLaYFXeu", - display_url: "pic.twitter.com/DCbLaYFXeu", - expanded_url: - "http://twitter.com/ph_toei/status/352644565875974144/photo/1", - type: "photo", - sizes: - { medium: {w: 480, h: 437, resize: "fit"}, - thumb: {w: 150, h: 150, resize: "crop"}, - small: {w: 340, h: 310, resize: "fit"}, - large: {w: 480, h: 437, resize: "fit"}}, - source_status_id: 352644565875974144, - source_status_id_str: "352644565875974144"}]}) - mes.stubs(:is_a?).with(Message).returns(true) - entity = Message::Entity.new(mes) - - assert_kind_of(String, entity.to_s) - assert_equal("【特価品】Celeron C1037U&HM77、デュアルLAN(82574L)を搭載したNAS向miniITXマザー Giada N70E-DR V2 14980円 goo.gl/HDK5i pic.twitter.com/DCbLaYFXeu", entity.to_s.inspect) - end - -end - diff --git a/test/core/test_message.rb b/test/core/test_message.rb index b095912c..e35ab4fa 100644 --- a/test/core/test_message.rb +++ b/test/core/test_message.rb @@ -18,13 +18,13 @@ class TC_Message < Test::Unit::TestCase end # !> ambiguous first argument; put parentheses or even spaces must "hierarchy check" do - toshi = User.new_ifnecessary(:id => 123456, :idname => 'toshi_a', :name => 'toshi') - miku = User.new_ifnecessary(:id => 393939, :idname => 'ha2ne39', :name => 'miku') - c1 = Message.new_ifnecessary(:id => 11, :message => '@ha2ne39 みくちゃああああああああああああん', :user => toshi, :created => Time.now) - c2 = Message.new_ifnecessary(:id => 12, :message => '@toshi_a なに', :user => miku, :replyto =>c1, :created => Time.now) - c3 = Message.new_ifnecessary(:id => 13, :message => '@ha2ne39 ぺろぺろぺろぺろ(^ω^)', :user => toshi, :replyto =>c2, :created => Time.now) - c4 = Message.new_ifnecessary(:id => 14, :message => '@toshi_a 垢消せ', :user => miku, :replyto =>c3, :created => Time.now) - c5 = Message.new_ifnecessary(:id => 15, :message => '@toshi_a キモい', :user => miku, :replyto =>c3, :created => Time.now) + toshi = User.new(:id => 123456, :idname => 'toshi_a', :name => 'toshi') + miku = User.new(:id => 393939, :idname => 'ha2ne39', :name => 'miku') + c1 = Message.new(:id => 11, :message => '@ha2ne39 みくちゃああああああああああああん', :user => toshi, :created => Time.now) + c2 = Message.new(:id => 12, :message => '@toshi_a なに', :user => miku, :replyto =>c1, :created => Time.now) + c3 = Message.new(:id => 13, :message => '@ha2ne39 ぺろぺろぺろぺろ(^ω^)', :user => toshi, :replyto =>c2, :created => Time.now) + c4 = Message.new(:id => 14, :message => '@toshi_a 垢消せ', :user => miku, :replyto =>c3, :created => Time.now) + c5 = Message.new(:id => 15, :message => '@toshi_a キモい', :user => miku, :replyto =>c3, :created => Time.now) assert_equal(c1, c2.receive_message) assert_kind_of(Message, c2.receive_message) assert_kind_of(Message, c1) # !> method redefined; discarding old inspect @@ -43,12 +43,12 @@ class TC_Message < Test::Unit::TestCase end must "around check" do - toshi = User.new_ifnecessary(:id => 123456, :idname => 'toshi_a', :name => 'toshi') - miku = User.new_ifnecessary(:id => 393939, :idname => 'ha2ne39', :name => 'miku') - c1 = Message.new_ifnecessary(:id => 21, :message => 'おはよう', :user => toshi, :created => Time.now) - c2 = Message.new_ifnecessary(:id => 22, :message => '@toshi_a おはよう', :user => miku, :replyto =>c1, :created => Time.now) - c3 = Message.new_ifnecessary(:id => 23, :message => '@ha2ne39 ぺろぺろぺろぺろ(^ω^)', :user => toshi, :replyto =>c2, :created => Time.now) - c4 = Message.new_ifnecessary(:id => 24, :message => '@toshi_a おはよう', :user => toshi, :replyto =>c1, :created => Time.now) + toshi = User.new(:id => 123456, :idname => 'toshi_a', :name => 'toshi') + miku = User.new(:id => 393939, :idname => 'ha2ne39', :name => 'miku') + c1 = Message.new(:id => 21, :message => 'おはよう', :user => toshi, :created => Time.now) + c2 = Message.new(:id => 22, :message => '@toshi_a おはよう', :user => miku, :replyto =>c1, :created => Time.now) + c3 = Message.new(:id => 23, :message => '@ha2ne39 ぺろぺろぺろぺろ(^ω^)', :user => toshi, :replyto =>c2, :created => Time.now) + c4 = Message.new(:id => 24, :message => '@toshi_a おはよう', :user => toshi, :replyto =>c1, :created => Time.now) c1.children << c2 c2.children << c3 c1.children << c4 @@ -57,23 +57,24 @@ class TC_Message < Test::Unit::TestCase end must "receive user detect" do - toshi = User.new_ifnecessary(:id => 123456, :idname => 'toshi_a', :name => 'toshi') - message = Message.new_ifnecessary(:id => 11, :message => '@ha2ne39 @mikutter_bot hey, miku!', :user => toshi, :created => Time.now) + toshi = User.new(:id => 123456, :idname => 'toshi_a', :name => 'toshi') + message = Message.new(:id => 11, :message => '@ha2ne39 @mikutter_bot hey, miku!', :user => toshi, :created => Time.now) + assert_equal '@ha2ne39 @mikutter_bot hey, miku!', message[:message] assert_equal ["ha2ne39", "mikutter_bot"], message.receive_user_screen_names end must "receive user not detect" do - toshi = User.new_ifnecessary(:id => 123456, :idname => 'toshi_a', :name => 'toshi') - message = Message.new_ifnecessary(:id => 11, :message => 'nemui', :user => toshi, :created => Time.now) + toshi = User.new(:id => 123456, :idname => 'toshi_a', :name => 'toshi') + message = Message.new(:id => 11, :message => 'nemui', :user => toshi, :created => Time.now) assert message.receive_user_screen_names.empty? end must "message to me" do - toshi = User.new_ifnecessary(:id => 156, :idname => 'toshi', :name => 'toshi') - toshi_a = User.new_ifnecessary(:id => 123456, :idname => 'toshi_a', :name => 'toshi_a') - toshi_b = User.new_ifnecessary(:id => 1234567, :idname => 'toshi_b', :name => 'toshi_b') - toshi_a_a = User.new_ifnecessary(:id => 156156, :idname => 'toshi_a_a', :name => 'toshi_a_a') - message = Message.new_ifnecessary(id: 11, message: "krile で where user == @toshi_a | user == @toshi_a_a だけのタブ作っただけでわずかにタブ切り替えが遅くなってるのわかると思うしアカウント切り替えは極端に遅くなってる", system: true, created: Time.now, receiver: toshi_b) + toshi = User.new(:id => 156, :idname => 'toshi', :name => 'toshi') + toshi_a = User.new(:id => 123456, :idname => 'toshi_a', :name => 'toshi_a') + toshi_b = User.new(:id => 1234567, :idname => 'toshi_b', :name => 'toshi_b') + toshi_a_a = User.new(:id => 156156, :idname => 'toshi_a_a', :name => 'toshi_a_a') + message = Message.new(id: 11, message: "krile で where user == @toshi_a | user == @toshi_a_a だけのタブ作っただけでわずかにタブ切り替えが遅くなってるのわかると思うしアカウント切り替えは極端に遅くなってる", user: toshi, created: Time.now, receiver: toshi_b) assert !message.receive_to?(toshi), 'toshi宛てのメッセージではない' assert message.receive_to?(toshi_a), 'toshi_a宛てのメッセージ' assert message.receive_to?(toshi_a_a), 'toshi_a_a宛てのメッセージ' diff --git a/test/core/test_retriever.rb b/test/core/test_retriever.rb index 81cce60b..d866ff50 100644 --- a/test/core/test_retriever.rb +++ b/test/core/test_retriever.rb @@ -4,11 +4,11 @@ require File.expand_path(File.dirname(__FILE__) + '/../helper') miquire :core, 'retriever' class Message < Retriever::Model - self.keys = [[:id, :int, :required], - [:title, :string], - [:desc, :string], - [:replyto, Message], - [:created, :time]] + field.int :id, required: true + field.string :title + field.string :desc + field.has :replyto, Message + field.time :created end |