/**
* Timeline
* Copyright 22.02.2015 by Michael Peter Christen, @0rb1t3r
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program in the file lgpl21.txt
* If not, see <http://www.gnu.org/licenses/>.
*/
package org.loklak.objects;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.loklak.data.DAO;
import org.loklak.data.IncomingMessageBuffer;
import org.loklak.data.DAO.IndexName;
import org.loklak.susi.SusiThought;
/**
* A timeline is a structure which holds tweet for the purpose of presentation
* There is no tweet retrieval method here, just an iterator which returns the tweets in reverse appearing order
*/
public class Timeline implements Iterable<MessageEntry> {
public static enum Order {
CREATED_AT("date"),
RETWEET_COUNT("long"),
FAVOURITES_COUNT("long");
String field_type;
Order(String field_type) {this.field_type = field_type;}
public String getMessageFieldName() {
return this.name().toLowerCase();
}
public String getMessageFieldType() {
return this.field_type;
}
}
private NavigableMap<String, MessageEntry> tweets; // the key is the date plus id of the tweet
private Map<String, UserEntry> users;
private int hits = -1;
private String scraperInfo = "";
final private Order order;
private String query;
private IndexName indexName;
private int cursor; // used for pagination, set this to the number of tweets returned so far to the user; they should be considered as unchangable
private long accessTime;
public Timeline(Order order) {
this.tweets = new ConcurrentSkipListMap<String, MessageEntry>();
this.users = new ConcurrentHashMap<String, UserEntry>();
this.order = order;
this.query = null;
this.indexName = null;
this.cursor = 0;
this.accessTime = System.currentTimeMillis();
}
public Timeline(Order order, String scraperInfo) {
this(order);
this.scraperInfo = scraperInfo;
}
public static Order parseOrder(String order) {
try {
return Order.valueOf(order.toUpperCase());
} catch (Throwable e) {
return Order.CREATED_AT;
}
}
public void clear() {
this.tweets.clear();
this.users.clear();
// we keep the other details (like order, scraperInfo and query) to be able to test with zero-size pushes
}
public Timeline setResultIndex(IndexName index) {
this.indexName = index;
return this;
}
public IndexName getResultIndex() {
return this.indexName;
}
public Timeline setScraperInfo(String info) {
this.scraperInfo = info;
return this;
}
public String getScraperInfo() {
return this.scraperInfo;
}
public long getAccessTime() {
return this.accessTime;
}
public Timeline updateAccessTime() {
this.accessTime = System.currentTimeMillis();
return this;
}
public Order getOrder() {
return this.order;
}
public String getQuery() {
return this.query;
}
public Timeline setQuery(String query) {
this.query = query;
return this;
}
/**
* gets the outer bound of the tweets returned to the user so far
* @return the cursor, the next starting point for tweet retrieval from the list, not shown so far to the user
*/
public int getCursor() {
return this.cursor;
}
/**
* sets the cursor to the outer bound of the visible tweet number.
* That means if no tweets had been shown to the user, the number is 0.
* @param newCursor the new cursor position which must be higher than the previous one.
* @return this
*/
public Timeline setCursor(int newCursor) {
if (newCursor > this.cursor) this.cursor = newCursor;
return this;
}
public int size() {
return this.tweets.size();
}
public Timeline reduceToMaxsize(final int maxsize) {
List<MessageEntry> m = new ArrayList<>();
Timeline t = new Timeline(this.order);
if (maxsize < 0) return t;
// remove tweets from this timeline
synchronized (tweets) {
while (this.tweets.size() > maxsize) m.add(this.tweets.remove(this.tweets.firstEntry().getKey()));
}
// create new timeline
for (MessageEntry me: m) {
t.addUser(this.users.get(me.getScreenName()));
t.addTweet(me);
}
// prune away users not needed any more in this structure
Set<String> screen_names = new HashSet<>();
for (MessageEntry me: this.tweets.values()) screen_names.add(me.getScreenName());
synchronized (this.users) {
Iterator<Map.Entry<String, UserEntry>> i = this.users.entrySet().iterator();
while (i.hasNext()) {
Map.Entry<String, UserEntry> e = i.next();
if (!screen_names.contains(e.getValue().getScreenName())) i.remove();
}
}
return t;
}
public Timeline add(MessageEntry tweet, UserEntry user) {
this.addUser(user);
this.addTweet(tweet);
return this;
}
private Timeline addUser(UserEntry user) {
assert user != null;
if (user != null) this.users.put(user.getScreenName(), user);
return this;
}
private Timeline addTweet(MessageEntry tweet) {
String key = "";
if (this.order == Order.RETWEET_COUNT) {
key = Long.toHexString(tweet.getRetweetCount());
while (key.length() < 16) key = "0" + key;
key = key + "_" + tweet.getIdStr();
} else if (this.order == Order.FAVOURITES_COUNT) {
key = Long.toHexString(tweet.getFavouritesCount());
while (key.length() < 16) key = "0" + key;
key = key + "_" + tweet.getIdStr();
} else {
key = Long.toHexString(tweet.getCreatedAt().getTime()) + "_" + tweet.getIdStr();
}
synchronized (tweets) {
MessageEntry precursorTweet = getPrecursorTweet();
if (precursorTweet != null && tweet.getCreatedAt().before(precursorTweet.getCreatedAt())) return this; // ignore this tweet in case it would change the list of shown tweets
this.tweets.put(key, tweet);
}
return this;
}
protected UserEntry getUser(String user_screen_name) {
return this.users.get(user_screen_name);
}
public UserEntry getUser(MessageEntry fromTweet) {
return this.users.get(fromTweet.getScreenName());
}
public void putAll(Timeline other) {
if (other == null) return;
assert this.order.equals(other.order);
for (Map.Entry<String, UserEntry> u: other.users.entrySet()) {
UserEntry t = this.users.get(u.getKey());
if (t == null || !t.containsProfileImage()) {
this.users.put(u.getKey(), u.getValue());
}
}
for (MessageEntry t: other) this.addTweet(t);
}
public MessageEntry getBottomTweet() {
synchronized (tweets) {
return this.tweets.firstEntry().getValue();
}
}
public MessageEntry getTopTweet() {
synchronized (tweets) {
return this.tweets.lastEntry().getValue();
}
}
private final Map<Integer, MessageEntry> precursorTweetCache = new ConcurrentHashMap<>();
/**
* get the precursor tweet, which is the latest tweet that the user has seen.
* It is the tweet which appears at the end of the list.
* This tweet may be used to compute the insert date which is valid for new tweets.
* Therefore there is a cache which contains the latest tweet shown to the user so fa.
* New tweets must have an entry date after that last tweet to create a stable list
* @return the last tweet that a user has seen. It is also the oldest tweet that the user has seen.
*/
private MessageEntry getPrecursorTweet() {
if (this.cursor == 0) return null;
MessageEntry m = this.precursorTweetCache.get(this.cursor);
if (m != null) return m;
synchronized (tweets) {
int count = 0;
for (MessageEntry messageEntry: this) {
if (++count == this.cursor) {
this.precursorTweetCache.put(this.cursor, messageEntry);
return messageEntry;
}
}
}
return null;
}
public List<MessageEntry> getNextTweets(int start, int maxCount) {
List<MessageEntry> tweets = new ArrayList<>();
synchronized (tweets) {
int count = 0;
for (MessageEntry messageEntry: this) {
if (count >= start) tweets.add(messageEntry);
if (tweets.size() >= maxCount) break;
count++;
}
if (start >= this.cursor) this.cursor = start + tweets.size();
}
return tweets;
}
public String toString() {
return toJSON(true, "search_metadata", "statuses").toString();
//return new ObjectMapper().writer().writeValueAsString(toMap(true));
}
public JSONObject toJSON(boolean withEnrichedData, String metadata_field_name, String statuses_field_name) throws JSONException {
JSONObject json = toSusi(withEnrichedData, new SusiThought(metadata_field_name, statuses_field_name));
json.getJSONObject(metadata_field_name).put("count", Integer.toString(this.tweets.size()));
json.put("peer_hash", DAO.public_settings.getPeerHash());
json.put("peer_hash_algorithm", DAO.public_settings.getPeerHashAlgorithm());
return json;
}
public SusiThought toSusi(boolean withEnrichedData) throws JSONException {
return toSusi(withEnrichedData, new SusiThought());
}
private SusiThought toSusi(boolean withEnrichedData, SusiThought json) throws JSONException {
json
.setQuery(this.query)
.setHits(Math.max(this.hits, this.size()));
if (this.scraperInfo.length() > 0) json.setScraperInfo(this.scraperInfo);
JSONArray statuses = new JSONArray();
for (MessageEntry t: this) {
UserEntry u = this.users.get(t.getScreenName());
statuses.put(t.toJSON(u, withEnrichedData, Integer.MAX_VALUE, ""));
}
json.setData(statuses);
return json;
}
/**
* the tweet iterator returns tweets in descending appearance order (top first)
*/
@Override
public Iterator<MessageEntry> iterator() {
return this.tweets.descendingMap().values().iterator();
}
/**
* compute the average time between any two consecutive tweets
* @return time in milliseconds
*/
public long period() {
if (this.size() < 1) return Long.MAX_VALUE;
// calculate the time based on the latest 20 tweets (or less)
long latest = 0;
long earliest = 0;
int count = 0;
for (MessageEntry messageEntry: this) {
if (latest == 0) {latest = messageEntry.created_at.getTime(); continue;}
earliest = messageEntry.created_at.getTime();
count++;
if (count >= 19) break;
}
if (count == 0) return Long.MAX_VALUE;
long timeInterval = latest - earliest;
long p = 1 + timeInterval / count;
return p < 4000 ? p / 4 + 3000 : p;
}
public void writeToIndex() {
IncomingMessageBuffer.addScheduler(this, true);
}
public Timeline setHits(int hits) {
this.hits = hits;
return this;
}
public int getHits() {
return this.hits == -1 ? this.size() : this.hits;
}
}