/******************************************************************************* * This file is part of Zandy. * * Zandy is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Zandy 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 Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Zandy. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package com.gimranov.zandy.app.data; import java.util.ArrayList; import java.util.HashSet; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteStatement; import android.util.Log; import com.gimranov.zandy.app.task.APIRequest; /** * Represents a Zotero collection of Item objects. Collections can * be iterated over, but it's quite likely that iteration will prove unstable * if you make changes to them while iterating. * * To put items in collections, add them using the add(..) methods, and save the * collection. * * @author ajlyon * */ public class ItemCollection extends HashSet<Item> { /** * What is this for? */ private static final long serialVersionUID = -4673800475017605707L; private static final String TAG = "com.gimranov.zandy.app.data.ItemCollection"; /** * Queue of dirty collections to be sent to the server */ public static ArrayList<ItemCollection> queue = new ArrayList<ItemCollection>(); private String id; private String title; private String key; private String etag; /** * Subcollections of this collection. This is accessed through * a lazy getter, which caches the value. This may at times be * incorrect, since we don't repopulate this to reflect changes * after first populating it. */ private ArrayList<ItemCollection> subcollections; /** * This is an approximate size that we have from the database-- it may be outdated * at times, but it's often better than wading through the database and figuring out * the size that way. * * This size is updated only when loading children; */ private int size; private ItemCollection parent; private String parentKey; public String dbId; public String dirty; /** * Timestamp of last update from server; this is an Atom-formatted * timestamp */ private String timestamp; public ItemCollection(String title) { setTitle(title); dirty = APIRequest.API_DIRTY; } public ItemCollection() { } /** * We call void remove(Item) to allow for queueing * the action for application on the server, via the API. * * When fromAPI is not true, queues a collection membership * request for the server as well. * * @param item * @param fromAPI False for collection memberships we receive from the server * @param db */ public boolean remove(Item item, boolean fromAPI, Database db) { String[] args = {dbId, item.dbId}; db.rawQuery("delete from itemtocollections where collection_id=? and item_id=?", args); if (!fromAPI) { APIRequest req = APIRequest.remove(item, this); req.status = APIRequest.REQ_NEW; req.save(db); } super.remove(item); return true; } /* Getters and setters */ public String getId() { return id; } public String getEtag() { return etag; } public void setEtag(String etag) { this.etag = etag; } public String getTimestamp() { return timestamp; } public void setTimestamp(String timestamp) { this.timestamp = timestamp; } public String getTitle() { return title; } public void setTitle(String title) { if (this.title != title) { this.title = title; this.dirty = APIRequest.API_DIRTY; } } /* I'm not sure how easy this is to propagate to the API */ public ItemCollection getParent(Database db) { if (parent != null) return parent; if (parentKey == "false") return null; if (parentKey != null) { parent = load(parentKey, db); } return parent; } public void setParent(ItemCollection parent) { this.parentKey = parent.getKey(); this.parent = parent; } public void setParent(String parentKey) { this.parentKey = parentKey; } /* These can't be propagated, so it only makes sense before the collection * has been saved to the API. */ public void setId(String id) { this.id = id; } public void setKey(String key) { this.key = key; } public String getKey() { return key; } /** * Returns the size, not as measured, but as listed in DB * @return */ public int getSize() { return size; } /** * Marks the collection as clean and clears the pending additions and * removals. * * Note that dirty markings don't matter until saved to the DB, so * this should be followed by a save. */ public void markClean() { dirty = APIRequest.API_CLEAN; } public boolean add(Item item, Database db) { return add(item, false, db); } /** * Adds the specified item to this collection. * * When fromAPI is not true, queues a collection membership * request for the server as well. * * @param item * @param fromAPI False for collection memberships we receive from the server * @param db * @return Whether this is a new item for the collection */ public boolean add(Item item, boolean fromAPI, Database db) { for (Item i : this) { if(i.equals(item)) { Log.d(TAG, "Item already in collection"); return false; } } super.add(item); Log.d(TAG, "Item added to collection"); if (!fromAPI) { Log.d(TAG, "Saving new collection membership request to database"); APIRequest req = APIRequest.add(item, this); req.status = APIRequest.REQ_NEW; req.save(db); } return true; } /** * Returns ArrayList of Item objects not in the specified ArrayList of item keys. Used for determining * when items have been deleted from a collection. */ public ArrayList<Item> notInKeys(ArrayList<String> keys) { ArrayList<Item> notThere = new ArrayList<Item>(); for (Item i : this) { if (!keys.contains(i.getKey())) notThere.add(i); } return notThere; } /** * Saves the collection metadata to the database. * * Does nothing with the collection children. */ public void save(Database db) { ItemCollection existing = load(key, db); if (existing == null) { try { SQLiteStatement insert = db.compileStatement("insert or replace into collections " + "(collection_name, collection_key, collection_parent, etag, dirty, collection_size, timestamp)" + " values (?, ?, ?, ?, ?, ?, ?)"); // Why, oh why does bind* use 1-based indexing? And cur.get* uses 0-based! insert.bindString(1, title); if (key == null) insert.bindNull(2); else insert.bindString(2, key); if (parentKey == null) insert.bindNull(3); else insert.bindString(3, parentKey); if (etag == null) insert.bindNull(4); else insert.bindString(4, etag); if (dirty == null) insert.bindNull(5); else insert.bindString(5, dirty); insert.bindLong(6, size); if (timestamp == null) insert.bindNull(7); else insert.bindString(7, timestamp); insert.executeInsert(); insert.clearBindings(); insert.close(); Log.d(TAG, "Saved collection with key: "+key); } catch (SQLiteException e) { Log.e(TAG, "Exception compiling or running insert statement", e); throw e; } // XXX we need a way to handle locally-created collections ItemCollection loaded = load(key, db); if (loaded == null) { Log.e(TAG, "Item didn't stick-- still nothing for key: "+key); } else { dbId = loaded.dbId; } } else { dbId = existing.dbId; try { SQLiteStatement update = db.compileStatement("update collections set " + "collection_name=?, etag=?, dirty=?, collection_size=?, timestamp=?" + " where _id=?"); update.bindString(1, title); if (etag == null) update.bindNull(2); else update.bindString(2, etag); if (dirty == null) update.bindNull(3); else update.bindString(3, dirty); update.bindLong(4, size); if (timestamp == null) update.bindNull(5); else update.bindString(5, timestamp); update.bindString(6, dbId); update.executeInsert(); update.clearBindings(); update.close(); Log.i(TAG, "Updating existing collection."); } catch (SQLiteException e) { Log.e(TAG, "Exception compiling or running update statement", e); } } db.close(); } /** * Saves the item-collection relationship. This saves the collection * itself as well. * @throws Exception If we can't save the collection or children */ public void saveChildren(Database db) { /* The size is about to be the size of the internal ArrayList, so * set it now so it'll be propagated to the database if the collection * is new. * * Save it now-- to fix the size, and to make sure we have a database ID. */ loadChildren(db); Log.d(TAG,"Collection has dbid: "+dbId); /* The saving is implemented by removing all the records for this collection * and saving them anew. This is a risky way to do things. One approach is to * wrap the operation in a transaction, or we could try to keep track of changes. */ HashSet<String> keys = new HashSet<String>(); for (Item i : this) { if (i.dbId == null) i.save(db); keys.add(i.dbId); } db.beginTransaction(); try { String[] cid = { this.dbId }; db.rawQuery("delete from itemtocollections where collection_id=?", cid); for (String i : keys) { String[] args = { this.dbId, i }; db.rawQuery( "insert into itemtocollections (collection_id, item_id) values (?, ?)", args); } db.setTransactionSuccessful(); } catch (Exception e) { Log.e(TAG, "Exception caught on saving collection children", e); } finally { db.endTransaction(); } // We can now get a proper and total count String[] args = { this.dbId }; Cursor cur = db.rawQuery( "select count(distinct item_id) from itemtocollections where collection_id=?", args); cur.moveToFirst(); if(!cur.isAfterLast()) this.size = cur.getInt(0); if (cur != null) cur.close(); save(db); } /** * Loads the Item members of the collection into the ArrayList<> * */ public void loadChildren(Database db) { if (dbId == null) save(db); Log.d(TAG, "Looking for the kids of a collection with id: "+dbId); String[] args = { dbId }; Cursor cursor = db.rawQuery("SELECT item_title, item_type, item_content, etag, dirty, items._id, item_key, item_year, item_creator, items.timestamp, item_children" + " FROM items, itemtocollections WHERE items._id = item_id AND collection_id=? ORDER BY item_title", args); if (cursor != null) { cursor.moveToFirst(); while (!cursor.isAfterLast()) { Item i = Item.load(cursor); Log.d(TAG,"Adding an item to the collection: "+i.getTitle()); size = this.size(); if (i != null) super.add(i); cursor.moveToNext(); } cursor.close(); } else { Log.d(TAG,"Cursor was null, so we still didn't get kids for the collection!"); } } /** * Gets the subcollections of the current collection. * * This is a lazy getter and won't check again after the first time. * @return */ public ArrayList<ItemCollection> getSubcollections(Database db) { if (this.subcollections != null) return this.subcollections; this.subcollections = new ArrayList<ItemCollection>(); String[] args = { this.key }; Cursor cur = db.query("collections", Database.COLLCOLS, "collection_parent=?", args, null, null, null, null); if (cur == null) { Log.d(TAG,"No subcollections found for collection: " + this.title); return this.subcollections; } do { ItemCollection collection = load(cur); if (collection == null) { Log.e(TAG, "Got a null collection when loading from cursor, in getSubcollections"); continue; } Log.d(TAG,"Found subcollection: " + collection.title); this.subcollections.add(collection); } while (cur.moveToNext() != false); if (cur != null) cur.close(); return this.subcollections; } /** * Loads and returns an ItemCollection for the specified collection key * Returns null when no match is found for the specified collKey * * @param collKey * @return */ public static ItemCollection load(String collKey, Database db) { if (collKey == null) return null; String[] cols = Database.COLLCOLS; String[] args = { collKey }; Log.i(TAG, "Loading collection with key: "+collKey); Cursor cur = db.query("collections", cols, "collection_key=?", args, null, null, null, null); ItemCollection coll = load(cur); if (coll == null) Log.i(TAG, "Null collection loaded!"); if (cur != null) cur.close(); return coll; } /** * Loads a collection from the specified Cursor, where the cursor was created using * the recommended query in Database.COLLCOLS * * Returns null when the specified cursor is null. * * Does not close the cursor! * * @param cur * @return An ItemCollection object for the current row of the Cursor */ public static ItemCollection load(Cursor cur) { ItemCollection coll = new ItemCollection(); if (cur == null) { return null; } coll.setTitle(cur.getString(0)); coll.setParent(cur.getString(1)); coll.etag = cur.getString(2); coll.dirty = cur.getString(3); coll.dbId = cur.getString(4); coll.setKey(cur.getString(5)); coll.size = cur.getInt(6); coll.timestamp = cur.getString(7); return coll; } /** * Identifies stale or missing collections in the database and queues them for syncing */ public static void queue(Database db) { Log.d(TAG,"Clearing dirty queue before repopulation"); queue.clear(); ItemCollection coll; String[] cols = Database.COLLCOLS; String[] args = { APIRequest.API_CLEAN }; Cursor cur = db.query("collections", cols, "dirty!=?", args, null, null, null, null); if (cur == null) { Log.d(TAG,"No dirty items found in database"); queue.clear(); return; } do { Log.d(TAG,"Adding collection to dirty queue"); coll = load(cur); queue.add(coll); } while (cur.moveToNext() != false); if (cur != null) cur.close(); } /** * Gives us ItemCollection objects to feed into something like UI * @return */ public static ArrayList<ItemCollection> getCollections(Database db) { ArrayList<ItemCollection> collections = new ArrayList<ItemCollection>(); ItemCollection coll; String[] cols = Database.COLLCOLS; Cursor cur = db.query("collections", cols, "", null, null, null, "collection_name", null); if (cur == null) { Log.d(TAG,"No collections found in database"); return collections; } do { Log.d(TAG,"Adding collection to collection list"); coll = load(cur); collections.add(coll); } while (cur.moveToNext() != false); if (cur != null) cur.close(); return collections; } /** * Gives us ItemCollection objects containing given item * to feed into something like UI * @return */ public static ArrayList<ItemCollection> getCollections(Item i, Database db) { ArrayList<ItemCollection> collections = new ArrayList<ItemCollection>(); ItemCollection coll; String[] args = { i.dbId }; Cursor cursor = db.rawQuery("SELECT collection_name, collection_parent," + " etag, dirty, collections._id, collection_key, collection_size," + " timestamp FROM collections, itemtocollections" + " WHERE collections._id = collection_id AND item_id=?" + " ORDER BY collection_name", args); if (cursor == null) { Log.d(TAG,"No collections found for item"); return collections; } do { Log.d(TAG,"Adding collection to collection list"); coll = load(cursor); collections.add(coll); } while (cursor.moveToNext() != false); if (cursor != null) cursor.close(); return collections; } /** * Gives us count of ItemCollection objects containing given item * to feed into something like UI * @return */ public static int getCollectionCount(Item i, Database db) { String[] args = { i.dbId }; Cursor cursor = db.rawQuery("SELECT COUNT(*) " + " FROM collections, itemtocollections" + " WHERE collections._id = collection_id AND item_id=?", args); if (cursor == null) { Log.d(TAG,"No collections found for item"); return 0; } int count = cursor.getInt(0); if (cursor != null) cursor.close(); return count; } }