/* * Released into the public domain * with no warranty of any kind, either expressed or implied. */ package org.klomp.snark.comments; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.InputStreamReader; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.util.AbstractSet; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import net.i2p.util.SecureFileOutputStream; /** * Store comments. * * Optimized for fast checking of duplicates, and retrieval of ratings. * Removes are not really removed, only marked as hidden, so * they don't reappear. * Duplicates are detected based on an approximate time range. * Max size of both elements and total text length is enforced. * * Supports persistence via save() and File constructor. * * NOT THREAD SAFE except for iterating AFTER the iterator() call. * * @since 0.9.31 */ public class CommentSet extends AbstractSet<Comment> { private final HashMap<Integer, List<Comment>> map; private int size, realSize; private int myRating; private int totalRating; private int ratingSize; private int totalTextSize; private long latestCommentTime; private boolean modified; public static final int MAX_SIZE = 256; // Comment.java enforces max text length of 512, but // we don't want 256*512 in memory per-torrent, so // track and enforce separately. // Assume most comments are short or null. private static final int MAX_TOTAL_TEXT_LEN = MAX_SIZE * 16; public CommentSet() { super(); map = new HashMap<Integer, List<Comment>>(4); } public CommentSet(Collection<Comment> coll) { super(); map = new HashMap<Integer, List<Comment>>(coll.size()); addAll(coll); } /** * File must be gzipped. * Need not be sorted. * See Comment.toPersistentString() for format. */ public CommentSet(File file) throws IOException { this(); BufferedReader br = null; try { br = new BufferedReader(new InputStreamReader(new GZIPInputStream(new FileInputStream(file)), "UTF-8")); String line = null; while ( (line = br.readLine()) != null) { Comment c = Comment.fromPersistentString(line); if (c != null) add(c); } } finally { if (br != null) try { br.close(); } catch (IOException ioe) {} } modified = false; } /** * File will be gzipped. * Not sorted, includes hidden. * See Comment.toPersistentString() for format. * Sets isModified() to false. */ public void save(File file) throws IOException { PrintWriter out = null; try { out = new PrintWriter(new OutputStreamWriter(new GZIPOutputStream(new SecureFileOutputStream(file)), "UTF-8")); for (List<Comment> l : map.values()) { for (Comment c : l) { out.println(c.toPersistentString()); } } if (out.checkError()) throw new IOException("Failed write to " + file); modified = false; } finally { if (out != null) out.close(); } } /** * Max length for strings enforced in Comment.java. * Max total length for strings enforced here. * Enforces max size for set */ @Override public boolean add(Comment c) { if (realSize >= MAX_SIZE && !c.isMine()) return false; String s = c.getText(); if (s != null && totalTextSize + s.length() > MAX_TOTAL_TEXT_LEN) return false; // If isMine and no text and rating changed, don't bother if (c.isMine() && c.getText() == null && c.getRating() == myRating) return false; Integer hc = Integer.valueOf(c.hashCode()); List<Comment> list = map.get(hc); if (list == null) { list = Collections.singletonList(c); map.put(hc, list); addStats(c); return true; } if (list.contains(c)) return false; if (list.size() == 1) { // presume unmodifiable singletonList List<Comment> nlist = new ArrayList<Comment>(2); nlist.add(list.get(0)); map.put(hc, nlist); list = nlist; } list.add(c); // If isMine and no text and comment changed, remove old ones if (c.isMine() && c.getText() == null) removeMyOldRatings(c.getID()); addStats(c); return true; } /** * Only hides the comment, doesn't really remove it. * @return true if present and not previously hidden */ @Override public boolean remove(Object o) { if (o == null || !(o instanceof Comment)) return false; Comment c = (Comment) o; Integer hc = Integer.valueOf(c.hashCode()); List<Comment> list = map.get(hc); if (list == null) return false; int i = list.indexOf(c); if (i >= 0) { Comment cc = list.get(i); if (!cc.isHidden()) { removeStats(cc); cc.setHidden(); return true; } } return false; } /** * Remove the id as retrieved from Comment.getID(). * Only hides the comment, doesn't really remove it. * This is for the UI. * * @return true if present and not previously hidden */ public boolean remove(int id) { // not the most efficient but should be rare. for (List<Comment> l : map.values()) { for (Comment c : l) { if (c.getID() == id) { return remove(c); } } } return false; } /** * Remove all ratings of mine with empty comments, * except the ID specified. */ private void removeMyOldRatings(int exceptID) { for (List<Comment> l : map.values()) { for (Comment c : l) { if (c.isMine() && c.getText() == null && c.getID() != exceptID && !c.isHidden()) { removeStats(c); c.setHidden(); } } } } /** may be hidden */ private void addStats(Comment c) { realSize++; if (!c.isHidden()) { size++; int r = c.getRating(); if (r > 0) { if (c.isMine()) { myRating = r; } else { totalRating += r; ratingSize++; } } long time = c.getTime(); if (time > latestCommentTime) latestCommentTime = time; } String t = c.getText(); if (t != null) totalTextSize += t.length(); modified = true; } /** call before setting hidden */ private void removeStats(Comment c) { if (!c.isHidden()) { size--; int r = c.getRating(); if (r > 0) { if (c.isMine()) { if (myRating == r) myRating = 0; } else { totalRating -= r; ratingSize--; } } modified = true; } } /** * Is not adjusted if the latest comment wasn't hidden but is then hidden. * @return the timestamp of the most recent non-hidden comment */ public long getLatestCommentTime() { return latestCommentTime; } /** * @return true if modified since instantiation */ public boolean isModified() { return modified; } /** * @return 0 if none, or 1-5 */ public int getMyRating() { return myRating; } /** * @return Number of ratings making up the average rating */ public int getRatingCount() { return ratingSize; } /** * @return 0 if none, or 1-5 */ public double getAverageRating() { if (ratingSize <= 0) return 0.0d; return totalRating / (double) ratingSize; } /** * Actually clears everything, including hidden. * Resets ratings to zero. */ @Override public void clear() { if (realSize > 0) { modified = true; realSize = 0; map.clear(); size = 0; myRating = 0; totalRating = 0; ratingSize = 0; totalTextSize = 0; } } /** * May be more than what the iterator returns, * we do additional deduping in the iterator. * * @return the non-hidden size */ public int size() { return size; } /** * Will be in reverse-sort order, i.e. newest-first. * The returned iterator is thread-safe after this call. * Changes after this call will not be reflected in the iterator. * iter.remove() has no effect on the underlying set. * Hidden comments not included. * * Returned values may be less than indicated in size() * due to additional deduping in the iterator. */ public Iterator<Comment> iterator() { List<Comment> list = new ArrayList<Comment>(size); for (List<Comment> l : map.values()) { int hc = l.get(0).hashCode(); List<Comment> prevList = map.get(Integer.valueOf(hc - 1)); for (Comment c : l) { if (!c.isHidden()) { // additional deduping at boundary if (prevList != null) { boolean dup = false; for (Comment pc : prevList) { if (c.equalsIgnoreTimestamp(pc)) { dup = true; break; } } if (dup) continue; } list.add(c); } } } Collections.sort(list); return list.iterator(); } }