| Classes in this File | Line Coverage | Branch Coverage | Complexity | ||||
| DocumentImpl |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$1 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$2 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$3 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$4 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$5 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$6 |
|
| 4.202898550724638;4.203 | ||||
| DocumentImpl$7 |
|
| 4.202898550724638;4.203 |
| 1 | /* | |
| 2 | * Created on Jul 29, 2007 by wyatt | |
| 3 | */ | |
| 4 | package org.homeunix.thecave.buddi.model.impl; | |
| 5 | ||
| 6 | import java.beans.Encoder; | |
| 7 | import java.beans.ExceptionListener; | |
| 8 | import java.beans.Expression; | |
| 9 | import java.beans.PersistenceDelegate; | |
| 10 | import java.beans.XMLEncoder; | |
| 11 | import java.io.File; | |
| 12 | import java.io.FileOutputStream; | |
| 13 | import java.io.IOException; | |
| 14 | import java.io.OutputStream; | |
| 15 | import java.util.ArrayList; | |
| 16 | import java.util.Calendar; | |
| 17 | import java.util.Date; | |
| 18 | import java.util.GregorianCalendar; | |
| 19 | import java.util.HashMap; | |
| 20 | import java.util.LinkedList; | |
| 21 | import java.util.List; | |
| 22 | import java.util.Map; | |
| 23 | import java.util.concurrent.Semaphore; | |
| 24 | import java.util.logging.Level; | |
| 25 | import java.util.logging.Logger; | |
| 26 | ||
| 27 | import javax.swing.JOptionPane; | |
| 28 | ||
| 29 | import org.homeunix.thecave.buddi.Const; | |
| 30 | import org.homeunix.thecave.buddi.i18n.BuddiKeys; | |
| 31 | import org.homeunix.thecave.buddi.i18n.keys.ButtonKeys; | |
| 32 | import org.homeunix.thecave.buddi.i18n.keys.ScheduleFrequency; | |
| 33 | import org.homeunix.thecave.buddi.model.Account; | |
| 34 | import org.homeunix.thecave.buddi.model.AccountType; | |
| 35 | import org.homeunix.thecave.buddi.model.BudgetCategory; | |
| 36 | import org.homeunix.thecave.buddi.model.Document; | |
| 37 | import org.homeunix.thecave.buddi.model.ModelObject; | |
| 38 | import org.homeunix.thecave.buddi.model.ScheduledTransaction; | |
| 39 | import org.homeunix.thecave.buddi.model.Source; | |
| 40 | import org.homeunix.thecave.buddi.model.Split; | |
| 41 | import org.homeunix.thecave.buddi.model.Transaction; | |
| 42 | import org.homeunix.thecave.buddi.model.TransactionSplit; | |
| 43 | import org.homeunix.thecave.buddi.model.prefs.PrefsModel; | |
| 44 | import org.homeunix.thecave.buddi.plugin.api.exception.DataModelProblemException; | |
| 45 | import org.homeunix.thecave.buddi.plugin.api.exception.InvalidValueException; | |
| 46 | import org.homeunix.thecave.buddi.plugin.api.exception.ModelException; | |
| 47 | import org.homeunix.thecave.buddi.plugin.api.util.TextFormatter; | |
| 48 | import org.homeunix.thecave.buddi.util.BuddiCryptoFactory; | |
| 49 | import org.homeunix.thecave.buddi.util.FileFunctions; | |
| 50 | import org.homeunix.thecave.buddi.view.dialogs.BuddiPasswordDialog; | |
| 51 | ||
| 52 | import ca.digitalcave.moss.application.document.AbstractDocument; | |
| 53 | import ca.digitalcave.moss.application.document.exception.DocumentSaveException; | |
| 54 | import ca.digitalcave.moss.collections.CompositeList; | |
| 55 | import ca.digitalcave.moss.collections.SortedArrayList; | |
| 56 | import ca.digitalcave.moss.common.DateUtil; | |
| 57 | import ca.digitalcave.moss.crypto.CipherException; | |
| 58 | ||
| 59 | ||
| 60 | /** | |
| 61 | * The main container class for the new data model, to be implemented in Buddi version 3.0. | |
| 62 | * This contains all the data, most of it in list form. This object is the root of the XML | |
| 63 | * file as serialized by XMLEncoder. | |
| 64 | * | |
| 65 | * You should *not* create this class by calling its constructor - you must create it using | |
| 66 | * one of the ModelFactory.createDocument methods. The factory will correctly initialize | |
| 67 | * the default types and budget categories. The only reason we did not make the default | |
| 68 | * constructor for this class to be non-public was because the XMLDecoder needs public | |
| 69 | * consructors to create objects at load time. | |
| 70 | * | |
| 71 | * @author wyatt | |
| 72 | */ | |
| 73 | 0 | public class DocumentImpl extends AbstractDocument implements ModelObject, Document { |
| 74 | //Store the password when loaded, and use the same one for save(). | |
| 75 | // This is obviously not the best practice to use | |
| 76 | // from a security point of view. However, if a malicious | |
| 77 | // third party has good enough access to the machine to be able | |
| 78 | // to read private Java objects, they will just read it when | |
| 79 | // we call MossCryptoFactory anyways - the password is handed | |
| 80 | // there in plain text as well. The window is already there - this | |
| 81 | // just increases the time it is available for. | |
| 82 | private char[] password; | |
| 83 | private int flags; | |
| 84 | ||
| 85 | //Convenience class for checking if objects are already entered. | |
| 86 | 3034 | private final Map<String, ModelObject> uidMap = new HashMap<String, ModelObject>(); |
| 87 | ||
| 88 | //User data objects | |
| 89 | 3034 | private List<Account> accounts = new SortedArrayList<Account>(); |
| 90 | 3034 | private List<BudgetCategory> budgetCategories = new SortedArrayList<BudgetCategory>(); |
| 91 | 3034 | private List<AccountType> accountTypes = new SortedArrayList<AccountType>(); |
| 92 | 3034 | private List<Transaction> transactions = new SortedArrayList<Transaction>(); |
| 93 | 3034 | private List<ScheduledTransaction> scheduledTransactions = new SortedArrayList<ScheduledTransaction>(); |
| 94 | ||
| 95 | //Model object data | |
| 96 | private Time modifiedTime; | |
| 97 | private String uid; | |
| 98 | ||
| 99 | 3034 | private final Logger logger = Logger.getLogger(DocumentImpl.class.getName()); |
| 100 | ||
| 101 | /** | |
| 102 | * By default, we start with one batch change enabled. This is because, otherwise, | |
| 103 | * the XMLDecoder will cause many model change events to be fired, which will result in | |
| 104 | * much longer load times. You must call finishBatchChange() before any change | |
| 105 | * events will be sent! ModelFactory does this for you automatically; as such, | |
| 106 | * you are much better off to use that to create Document objects. | |
| 107 | * | |
| 108 | * (In fact, this constructor would be private, except for XMLDecoder's need for a public | |
| 109 | * constructor. Don't use this unless you know exactly what you are doing!) | |
| 110 | */ | |
| 111 | 3034 | public DocumentImpl() { |
| 112 | 3034 | setMinimumChangeEventPeriod(1000); |
| 113 | 3034 | startBatchChange(); |
| 114 | 3034 | } |
| 115 | ||
| 116 | private void doBackupDataFile() { | |
| 117 | //Make a backup of the file... | |
| 118 | //Backup the file, now that we know it is good... | |
| 119 | try{ | |
| 120 | 0 | if (getFile() != null){ |
| 121 | //Use a rotating backup file, of form 'Data.X.buddi' | |
| 122 | // The one with the smallest number X is the most recent. | |
| 123 | 0 | String fileBase = getFile().getAbsolutePath().replaceAll(Const.DATA_FILE_EXTENSION + "$", ""); |
| 124 | 0 | for (int i = PrefsModel.getInstance().getNumberOfBackups() - 2; i >= 0; i--){ |
| 125 | 0 | File tempBackupDest = new File(fileBase + "_" + (i + 1) + Const.BACKUP_FILE_EXTENSION); |
| 126 | 0 | File tempBackupSource = new File(fileBase + "_" + i + Const.BACKUP_FILE_EXTENSION); |
| 127 | 0 | if (tempBackupSource.exists()){ |
| 128 | 0 | FileFunctions.copyFile(tempBackupSource, tempBackupDest); |
| 129 | 0 | logger.finest("Moving " + tempBackupSource + " to " + tempBackupDest); |
| 130 | } | |
| 131 | } | |
| 132 | 0 | File tempBackupDest = new File(fileBase + "_0" + Const.BACKUP_FILE_EXTENSION); |
| 133 | 0 | FileFunctions.copyFile(getFile(), tempBackupDest); |
| 134 | 0 | if (Const.DEVEL) logger.finest("Backing up file to " + tempBackupDest); |
| 135 | } | |
| 136 | } | |
| 137 | 0 | catch(IOException ioe){ |
| 138 | 0 | logger.log(Level.WARNING, "Problem backing up data files", ioe); |
| 139 | } | |
| 140 | 0 | catch (RuntimeException re){ |
| 141 | 0 | logger.log(Level.SEVERE, "Runtime Exception encountered when backing up data file!", re); |
| 142 | 0 | } |
| 143 | 0 | } |
| 144 | ||
| 145 | public List<Account> getAccounts() { | |
| 146 | 195003 | checkLists(); |
| 147 | 195003 | return accounts; |
| 148 | } | |
| 149 | public void setAccounts(List<Account> accounts) { | |
| 150 | 0 | this.accounts = new SortedArrayList<Account>(); |
| 151 | 0 | this.accounts.addAll(accounts); |
| 152 | 0 | } |
| 153 | ||
| 154 | public List<BudgetCategory> getBudgetCategories() { | |
| 155 | 82415 | return budgetCategories; |
| 156 | } | |
| 157 | public void setBudgetCategories(List<BudgetCategory> budgetCategories) { | |
| 158 | 0 | this.budgetCategories = new SortedArrayList<BudgetCategory>(); |
| 159 | 0 | this.budgetCategories.addAll(budgetCategories); |
| 160 | 0 | } |
| 161 | public List<ScheduledTransaction> getScheduledTransactions() { | |
| 162 | 10953 | checkLists(); |
| 163 | 10953 | return scheduledTransactions; |
| 164 | } | |
| 165 | public void setScheduledTransactions(List<ScheduledTransaction> scheduledTransactions) { | |
| 166 | 0 | this.scheduledTransactions = new SortedArrayList<ScheduledTransaction>(); |
| 167 | 0 | this.scheduledTransactions.addAll(scheduledTransactions); |
| 168 | 0 | } |
| 169 | public List<Transaction> getTransactions() { | |
| 170 | 54780 | checkLists(); |
| 171 | 54780 | return transactions; |
| 172 | } | |
| 173 | public void setTransactions(List<Transaction> transactions) { | |
| 174 | 0 | this.transactions = new SortedArrayList<Transaction>(); |
| 175 | 0 | this.transactions.addAll(transactions); |
| 176 | 0 | } |
| 177 | public List<AccountType> getAccountTypes() { | |
| 178 | 64100 | checkLists(); |
| 179 | 64100 | return accountTypes; |
| 180 | } | |
| 181 | public void setAccountTypes(List<AccountType> types) { | |
| 182 | 0 | this.accountTypes = new SortedArrayList<AccountType>(); |
| 183 | 0 | this.accountTypes.addAll(types); |
| 184 | 0 | } |
| 185 | ||
| 186 | public void setFlag(int flag, boolean set) { | |
| 187 | 1 | if (set) |
| 188 | 1 | this.flags |= flag; |
| 189 | else | |
| 190 | 0 | this.flags = this.flags & ~flag; |
| 191 | 1 | } |
| 192 | ||
| 193 | public void addAccount(Account account) throws ModelException { | |
| 194 | 0 | account.setDocument(this); |
| 195 | 0 | checkValid(account, true, false); |
| 196 | 0 | accounts.add(account); |
| 197 | // Collections.sort(accounts); | |
| 198 | 0 | setChanged(); |
| 199 | 0 | } |
| 200 | public void addAccountType(AccountType type) throws ModelException { | |
| 201 | 27306 | type.setDocument(this); |
| 202 | 27306 | checkValid(type, true, false); |
| 203 | 27306 | accountTypes.add(type); |
| 204 | // Collections.sort(budgetCategories); | |
| 205 | 27306 | setChanged(); |
| 206 | 27306 | } |
| 207 | public void addBudgetCategory(BudgetCategory budgetCategory) throws ModelException { | |
| 208 | 30340 | budgetCategory.setDocument(this); |
| 209 | 30340 | checkValid(budgetCategory, true, false); |
| 210 | 30340 | budgetCategories.add(budgetCategory); |
| 211 | // Collections.sort(budgetCategories); | |
| 212 | 30340 | setChanged(); |
| 213 | 30340 | } |
| 214 | public void addScheduledTransaction(ScheduledTransaction scheduledTransaction) throws ModelException { | |
| 215 | 0 | scheduledTransaction.setDocument(this); |
| 216 | 0 | checkValid(scheduledTransaction, true, false); |
| 217 | 0 | scheduledTransactions.add(scheduledTransaction); |
| 218 | // Collections.sort(scheduledTransactions); | |
| 219 | 0 | setChanged(); |
| 220 | 0 | } |
| 221 | public void addTransaction(Transaction transaction) throws ModelException { | |
| 222 | 0 | transaction.setDocument(this); |
| 223 | 0 | checkValid(transaction, true, false); |
| 224 | 0 | transactions.add(transaction); |
| 225 | // Collections.sort(transactions); | |
| 226 | 0 | setChanged(); |
| 227 | 0 | } |
| 228 | public Account getAccount(String name) { | |
| 229 | 0 | for (Account a : getAccounts()) { //Try strict matching first |
| 230 | 0 | if (a.getName().equals(name)) |
| 231 | 0 | return a; |
| 232 | } | |
| 233 | 0 | for (Account a : getAccounts()) { //Next try to relax it, to ignore case |
| 234 | 0 | if (a.getName().equalsIgnoreCase(name)) |
| 235 | 0 | return a; |
| 236 | } | |
| 237 | 0 | for (Account a : getAccounts()) { //Finally try checking full name |
| 238 | 0 | if (a.getFullName().equalsIgnoreCase(name)) |
| 239 | 0 | return a; |
| 240 | } | |
| 241 | ||
| 242 | 0 | return null; |
| 243 | } | |
| 244 | public AccountType getAccountType(String name) { | |
| 245 | 0 | for (AccountType at : getAccountTypes()) { |
| 246 | 0 | if (at.getName().equals(name)) |
| 247 | 0 | return at; |
| 248 | } | |
| 249 | 0 | for (AccountType at : getAccountTypes()) { |
| 250 | 0 | if (at.getName().equalsIgnoreCase(name)) |
| 251 | 0 | return at; |
| 252 | } | |
| 253 | 0 | return null; |
| 254 | } | |
| 255 | public BudgetCategory getBudgetCategory(String fullName) { | |
| 256 | 0 | for (BudgetCategory bc : getBudgetCategories()) { |
| 257 | 0 | if (bc.getFullName().equals(fullName)) |
| 258 | 0 | return bc; |
| 259 | } | |
| 260 | 0 | for (BudgetCategory bc : getBudgetCategories()) { |
| 261 | 0 | if (bc.getFullName().equalsIgnoreCase(fullName)) |
| 262 | 0 | return bc; |
| 263 | } | |
| 264 | 0 | return null; |
| 265 | } | |
| 266 | public ModelObject getObjectByUid(String uid) { | |
| 267 | 0 | throw new RuntimeException("Method not implemented"); |
| 268 | } | |
| 269 | ||
| 270 | 3034 | private List<Source> sources = null; |
| 271 | @SuppressWarnings("unchecked") | |
| 272 | public List<Source> getSources() { | |
| 273 | 0 | if (sources == null) |
| 274 | 0 | sources = new CompositeList<Source>(true, false, getAccounts(), getBudgetCategories()); |
| 275 | 0 | return sources; |
| 276 | } | |
| 277 | public List<Transaction> getTransactions(Date startDate, Date endDate) { | |
| 278 | 0 | return new FilteredLists.TransactionListFilteredByDate(this, getTransactions(), startDate, endDate); |
| 279 | } | |
| 280 | public List<Transaction> getTransactions(Source source, Date startDate, Date endDate) { | |
| 281 | 19140 | return new FilteredLists.TransactionListFilteredBySource( |
| 282 | this, | |
| 283 | new FilteredLists.TransactionListFilteredByDate(this, getTransactions(), startDate, endDate), | |
| 284 | source); | |
| 285 | } | |
| 286 | public List<Transaction> getTransactions(Source source) { | |
| 287 | 0 | return new FilteredLists.TransactionListFilteredBySource(this, getTransactions(), source); |
| 288 | } | |
| 289 | public void removeAccount(Account account) throws ModelException { | |
| 290 | 0 | if (getTransactions(account).size() > 0) |
| 291 | 0 | throw new ModelException("Cannot remove account " + account + "; it contains transactions"); |
| 292 | 0 | for (ScheduledTransaction st : getScheduledTransactions()) |
| 293 | 0 | if (st.getFrom().equals(account) |
| 294 | || st.getTo().equals(account)) | |
| 295 | 0 | throw new ModelException("Cannot remove account " + account + "; it contains scheduled transactions"); |
| 296 | 0 | accounts.remove(account); |
| 297 | 0 | setChanged(); |
| 298 | 0 | } |
| 299 | public void removeAccountType(AccountType type) throws ModelException { | |
| 300 | 0 | for (Account a : getAccounts()) { |
| 301 | 0 | if (a.getAccountType().equals(type)) |
| 302 | 0 | throw new ModelException("Cannot remove account type " + type + "; it is referred to by " + a); |
| 303 | } | |
| 304 | 0 | accountTypes.remove(type); |
| 305 | 0 | setChanged(); |
| 306 | 0 | } |
| 307 | ||
| 308 | public void removeBudgetCategory(BudgetCategory budgetCategory) throws ModelException { | |
| 309 | //We call the recursive check to ensure that all descendants of this budget category | |
| 310 | // are cleared to be deleted. If one is not, we cancel the operation. | |
| 311 | 0 | recursiveCheckRemoveBudgetCategory(budgetCategory); |
| 312 | ||
| 313 | 0 | budgetCategories.remove(budgetCategory); |
| 314 | 0 | setChanged(); |
| 315 | 0 | } |
| 316 | ||
| 317 | private void recursiveCheckRemoveBudgetCategory(BudgetCategory budgetCategory) throws ModelException { | |
| 318 | 0 | if (getTransactions(budgetCategory).size() > 0) |
| 319 | 0 | throw new ModelException("Cannot remove budget category " + budgetCategory + "; it is referenced by at least one transaction"); |
| 320 | 0 | for (ScheduledTransaction st : getScheduledTransactions()) |
| 321 | 0 | if (st.getFrom().equals(budgetCategory) |
| 322 | || st.getTo().equals(budgetCategory)) | |
| 323 | 0 | throw new ModelException("Cannot remove budget category " + budgetCategory + "; it contains scheduled transactions"); |
| 324 | ||
| 325 | 0 | for (BudgetCategory bc : budgetCategory.getChildren()) { |
| 326 | 0 | recursiveCheckRemoveBudgetCategory(bc); |
| 327 | } | |
| 328 | 0 | } |
| 329 | ||
| 330 | public void removeScheduledTransaction(ScheduledTransaction scheduledTransaction) throws ModelException { | |
| 331 | 0 | scheduledTransactions.remove(scheduledTransaction); |
| 332 | 0 | setChanged(); |
| 333 | 0 | } |
| 334 | public void removeTransaction(Transaction transaction) throws ModelException { | |
| 335 | 0 | transactions.remove(transaction); |
| 336 | 0 | setChanged(); |
| 337 | 0 | } |
| 338 | /** | |
| 339 | * Saves the data file to the current file. If the file has not yet been set, | |
| 340 | * this method silently returns. | |
| 341 | * @throws SaveModelException | |
| 342 | */ | |
| 343 | public void save() throws DocumentSaveException { | |
| 344 | 0 | saveWrapper(getFile(), false); |
| 345 | 0 | } |
| 346 | ||
| 347 | /** | |
| 348 | * Saves the data file to the specified file. If the specified file is | |
| 349 | * null, silently return without error and without saving. | |
| 350 | * @param file | |
| 351 | * @param flags. Flags to set for saving. AND together for multiple flags. | |
| 352 | * @throws SaveModelException | |
| 353 | */ | |
| 354 | public void saveAs(File file) throws DocumentSaveException { | |
| 355 | 0 | saveWrapper(file, true); |
| 356 | 0 | } |
| 357 | ||
| 358 | /** | |
| 359 | * Saves the autosave file, bypassing backups, etc. | |
| 360 | * @param file | |
| 361 | * @throws DocumentSaveException | |
| 362 | */ | |
| 363 | 3034 | private final Semaphore autosaveMutex = new Semaphore(1); |
| 364 | public void saveAuto(File file) throws DocumentSaveException { | |
| 365 | //Save the file | |
| 366 | try { | |
| 367 | 0 | if (!isBatchChange()){ |
| 368 | 0 | BuddiCryptoFactory factory = new BuddiCryptoFactory(); |
| 369 | ||
| 370 | 0 | final OutputStream os = factory.getEncryptedStream(new FileOutputStream(file), password); |
| 371 | ||
| 372 | //Clone the file. This is to decrease the time needed to save large files. | |
| 373 | 0 | final Document clone = clone(); |
| 374 | ||
| 375 | 0 | new Thread(new Runnable(){ |
| 376 | public void run() { | |
| 377 | 0 | if (autosaveMutex.tryAcquire()){ |
| 378 | try { | |
| 379 | 0 | clone.saveToStream(os); |
| 380 | 0 | } catch (DocumentSaveException e) { |
| 381 | 0 | logger.log(Level.WARNING, "There was an error when autosaving the file.", e); |
| 382 | 0 | } |
| 383 | ||
| 384 | 0 | autosaveMutex.release(); |
| 385 | } | |
| 386 | else { | |
| 387 | 0 | logger.warning("Did not autosave, as there is another process already waiting."); |
| 388 | } | |
| 389 | 0 | } |
| 390 | }).start(); | |
| 391 | } | |
| 392 | } | |
| 393 | 0 | catch (CipherException ce){ |
| 394 | //This means that there is something seriously wrong with the encryption methods. | |
| 395 | // Perhaps the user's platform does not support the given methods. | |
| 396 | // Notify the user, and cancel the save. | |
| 397 | 0 | throw new DocumentSaveException(ce); |
| 398 | } | |
| 399 | 0 | catch (IOException ioe){ |
| 400 | //This means that there was something wrong with the given file, or writing to | |
| 401 | // it. Perhaps the user does not have write access, the folder does not exist, | |
| 402 | // or something similar. Notify the user, and cancel the save. | |
| 403 | 0 | throw new DocumentSaveException(ioe); |
| 404 | } | |
| 405 | 0 | catch (CloneNotSupportedException cnse){ |
| 406 | 0 | logger.warning("There was a problem cloning the data model, prior to auto saving."); |
| 407 | 0 | throw new DocumentSaveException(cnse); |
| 408 | 0 | } |
| 409 | 0 | } |
| 410 | ||
| 411 | /** | |
| 412 | * Wait until any current save operations are completed. | |
| 413 | * @return | |
| 414 | * @throws InterruptedException | |
| 415 | */ | |
| 416 | public void waitUntilFinishedSaving() throws InterruptedException{ | |
| 417 | 0 | saveMutex.acquire(); |
| 418 | 0 | saveMutex.release(); |
| 419 | 0 | } |
| 420 | ||
| 421 | public boolean isCurrentlySaving(){ | |
| 422 | 0 | return saveMutex.availablePermits() == 0; |
| 423 | } | |
| 424 | ||
| 425 | /** | |
| 426 | * This is a simple wrapper around the saveInternal method, which adds some key functionality. | |
| 427 | * This is especially related to handling autoSave files, and will remove the autosave | |
| 428 | * file associated with this file if it exists. | |
| 429 | * @param file | |
| 430 | * @param flags | |
| 431 | * @param resetUid | |
| 432 | * @throws DocumentSaveException | |
| 433 | */ | |
| 434 | 3034 | private final Semaphore saveMutex = new Semaphore(1); |
| 435 | private void saveWrapper(final File file, boolean resetUid) throws DocumentSaveException, ConcurrentSaveException { | |
| 436 | 0 | if (!saveMutex.tryAcquire()){ |
| 437 | 0 | throw new ConcurrentSaveException("Did not save file, as there is another thread currently saving."); |
| 438 | } | |
| 439 | //Do a rotating backup before saving | |
| 440 | 0 | doBackupDataFile(); |
| 441 | ||
| 442 | //Reset the document's UID. This needs to be done when saving to a different file, | |
| 443 | // or else you cannot have multiple files open at the same time. | |
| 444 | 0 | if (resetUid) |
| 445 | 0 | setUid(getGeneratedUid(this)); |
| 446 | ||
| 447 | //Check if we need to reset the password | |
| 448 | 0 | if ((flags & RESET_PASSWORD) != 0){ |
| 449 | 0 | password = null; |
| 450 | 0 | setFlag(RESET_PASSWORD, false); |
| 451 | } | |
| 452 | ||
| 453 | ||
| 454 | //Check if we need to change the password | |
| 455 | 0 | if ((flags & CHANGE_PASSWORD) != 0){ |
| 456 | 0 | BuddiPasswordDialog passwordDialog = new BuddiPasswordDialog(); |
| 457 | 0 | password = passwordDialog.askForPassword(true, false); |
| 458 | 0 | setFlag(CHANGE_PASSWORD, false); |
| 459 | } | |
| 460 | ||
| 461 | 0 | Thread saveThread = new Thread(new Runnable(){ |
| 462 | public void run() { | |
| 463 | 0 | OutputStream os = null; |
| 464 | try { | |
| 465 | //Clone the file. This is to decrease the time needed to save large files. | |
| 466 | 0 | Document clone = DocumentImpl.this.clone(); |
| 467 | ||
| 468 | //Save the file | |
| 469 | 0 | BuddiCryptoFactory factory = new BuddiCryptoFactory(); |
| 470 | // File tempFile = new File(file.getAbsolutePath() + ".temp"); | |
| 471 | 0 | os = factory.getEncryptedStream(new FileOutputStream(file), password); |
| 472 | ||
| 473 | 0 | clone.saveToStream(os); |
| 474 | ||
| 475 | // //Windows does not support renameTo'ing a file to an existing file. Thus, we need | |
| 476 | // // to rename the existing file to a '.old' file, rename the temp file to the | |
| 477 | // // data file, and remove the .old file if all goes well. While it would be simpler | |
| 478 | // // to just remove the data file initially, by renaming it first, we have at least | |
| 479 | // // some assurance that we are able to recover data if needed. | |
| 480 | // File oldFile = new File(file.getAbsolutePath() + ".old"); | |
| 481 | // if (oldFile.exists() && !oldFile.delete()) | |
| 482 | // throw new IOException("Unable to delete existing file '" + oldFile + "' in preparation for moving temp file."); | |
| 483 | // if (file.exists() && !file.renameTo(oldFile)) | |
| 484 | // throw new IOException("Unable to rename existing data file '" + file + "' to '" + oldFile + "'."); | |
| 485 | // if (!tempFile.renameTo(file)) | |
| 486 | // throw new IOException("Unable to rename temp file '" + tempFile + "' to data file '" + file + "'."); | |
| 487 | // if (oldFile.exists() && !oldFile.delete()) | |
| 488 | // logger.error("Unable to delete old data file '" + oldFile + "'. This may cause problems the next time we try to save."); | |
| 489 | } | |
| 490 | 0 | catch (CipherException ce){ |
| 491 | //This means that there is something seriously wrong with the encryption methods. | |
| 492 | // Perhaps the user's platform does not support the given methods. | |
| 493 | // Notify the user, and cancel the save. | |
| 494 | 0 | logger.log(Level.WARNING, "There was a problem related to the encryption of the data file. Perhaps your Java implemntation does not support the required encryption methods?", ce); |
| 495 | } | |
| 496 | 0 | catch (IOException ioe){ |
| 497 | //This means that there was something wrong with the given file, or writing to | |
| 498 | // it. Perhaps the user does not have write access, the folder does not exist, | |
| 499 | // or something similar. Notify the user, and cancel the save. | |
| 500 | 0 | logger.log(Level.WARNING, "There was an IO error while saving your file. Please ensure that the desired folder exists, and that you have write access to it.", ioe); |
| 501 | } | |
| 502 | 0 | catch (DocumentSaveException dse){ |
| 503 | 0 | logger.log(Level.WARNING, "There was a problem saving the document.", dse); |
| 504 | } | |
| 505 | 0 | catch (CloneNotSupportedException cnse){ |
| 506 | 0 | logger.log(Level.WARNING, "There was a problem cloning the data model, prior to saving.", cnse); |
| 507 | } | |
| 508 | finally { | |
| 509 | 0 | if (os != null) { |
| 510 | try { | |
| 511 | 0 | os.close(); |
| 512 | } | |
| 513 | 0 | catch (IOException ioe) { |
| 514 | 0 | logger.log(Level.SEVERE, "Problem encountered while trying to close Output Stream", ioe); |
| 515 | 0 | } |
| 516 | } | |
| 517 | } | |
| 518 | ||
| 519 | 0 | saveMutex.release(); |
| 520 | 0 | } |
| 521 | }); | |
| 522 | ||
| 523 | 0 | saveThread.setDaemon(false); |
| 524 | 0 | saveThread.start(); |
| 525 | ||
| 526 | //Save where we last saved this file. | |
| 527 | 0 | setFile(file); |
| 528 | // PrefsModel.getInstance().setLastOpenedDataFile(file); | |
| 529 | ||
| 530 | //Reset the changed flag. | |
| 531 | 0 | resetChanged(); |
| 532 | ||
| 533 | //Remove any auto save files which are associated with this document | |
| 534 | 0 | File autosave = ModelFactory.getAutoSaveLocation(file); |
| 535 | 0 | if (autosave.exists() && autosave.isFile()){ |
| 536 | 0 | if (!autosave.delete()) |
| 537 | 0 | logger.warning("Unable to delete file " + autosave + "; you may be prompted to load this file next time you load."); |
| 538 | } | |
| 539 | 0 | } |
| 540 | ||
| 541 | /** | |
| 542 | * Very simple save method. Streams the document to XML using the | |
| 543 | * XMLEncoder, optionally using an encrypted output stream if the password is set. | |
| 544 | * | |
| 545 | * @param file | |
| 546 | * @param flags | |
| 547 | * @throws DocumentSaveException | |
| 548 | */ | |
| 549 | public void saveToStream(OutputStream os) throws DocumentSaveException { | |
| 550 | //We don't want to be firing change events in the middle of a save | |
| 551 | 0 | startBatchChange(); |
| 552 | ||
| 553 | 0 | XMLEncoder encoder = new XMLEncoder(os); |
| 554 | 0 | encoder.setExceptionListener(new ExceptionListener(){ |
| 555 | public void exceptionThrown(Exception e) { | |
| 556 | 0 | logger.log(Level.WARNING, "Error writing XML", e); |
| 557 | 0 | } |
| 558 | }); | |
| 559 | 0 | encoder.setPersistenceDelegate(File.class, new PersistenceDelegate(){ |
| 560 | protected Expression instantiate(Object oldInstance, Encoder out ){ | |
| 561 | 0 | File file = (File) oldInstance; |
| 562 | 0 | String filePath = file.getAbsolutePath(); |
| 563 | 0 | return new Expression(file, file.getClass(), "new", new Object[]{filePath} ); |
| 564 | } | |
| 565 | }); | |
| 566 | 0 | encoder.setPersistenceDelegate(Date.class, new PersistenceDelegate(){ |
| 567 | @Override | |
| 568 | protected Expression instantiate(Object oldInstance, Encoder out) { | |
| 569 | 0 | Date date = (Date) oldInstance; |
| 570 | 0 | return new Expression( |
| 571 | date, | |
| 572 | DateUtil.class, | |
| 573 | "getDate", | |
| 574 | new Object[]{DateUtil.getYear(date), DateUtil.getMonth(date), DateUtil.getDay(date)}); | |
| 575 | } | |
| 576 | }); | |
| 577 | 0 | encoder.setPersistenceDelegate(Day.class, new PersistenceDelegate(){ |
| 578 | @Override | |
| 579 | protected Expression instantiate(Object oldInstance, Encoder out) { | |
| 580 | 0 | Day date = (Day) oldInstance; |
| 581 | 0 | return new Expression( |
| 582 | date, | |
| 583 | Day.class, | |
| 584 | "new", | |
| 585 | new Object[]{DateUtil.getYear(date), DateUtil.getMonth(date), DateUtil.getDay(date)}); | |
| 586 | } | |
| 587 | }); | |
| 588 | 0 | encoder.setPersistenceDelegate(Time.class, new PersistenceDelegate(){ |
| 589 | @Override | |
| 590 | protected Expression instantiate(Object oldInstance, Encoder out) { | |
| 591 | 0 | Time time = (Time) oldInstance; |
| 592 | 0 | return new Expression( |
| 593 | time, | |
| 594 | Time.class, | |
| 595 | "new", | |
| 596 | new Object[]{time.getTime()}); | |
| 597 | } | |
| 598 | }); | |
| 599 | ||
| 600 | ||
| 601 | 0 | encoder.writeObject(this); |
| 602 | 0 | encoder.flush(); |
| 603 | 0 | encoder.close(); |
| 604 | ||
| 605 | 0 | finishBatchChange(); |
| 606 | 0 | } |
| 607 | /** | |
| 608 | * Updates the balances of all accounts. Iterates through all accounts, and | |
| 609 | * calls the updateBalance() method for each. | |
| 610 | */ | |
| 611 | public void updateAllBalances(){ | |
| 612 | 3034 | this.startBatchChange(); |
| 613 | 3034 | for (Account a : getAccounts()) { |
| 614 | 0 | a.updateBalance(); |
| 615 | } | |
| 616 | 3034 | this.finishBatchChange(); |
| 617 | 3034 | } |
| 618 | ||
| 619 | public Date getModifiedDate() { | |
| 620 | 0 | return modifiedTime; |
| 621 | } | |
| 622 | public void setModified(Date modifiedDate) { | |
| 623 | 175972 | this.modifiedTime = new Time(modifiedDate); |
| 624 | 175972 | } |
| 625 | public void setModified(Time modifiedTime){ | |
| 626 | 0 | this.modifiedTime = modifiedTime; |
| 627 | 0 | } |
| 628 | public void setChanged(){ | |
| 629 | 175972 | setModified(new Date()); |
| 630 | 175972 | super.setChanged(); |
| 631 | 175972 | } |
| 632 | public String getUid() { | |
| 633 | 235463 | if (uid == null || uid.length() == 0){ |
| 634 | 3034 | setUid(getGeneratedUid(this)); |
| 635 | } | |
| 636 | 235463 | return uid; |
| 637 | } | |
| 638 | public void setUid(String uid) { | |
| 639 | 3034 | this.uid = uid; |
| 640 | 3034 | } |
| 641 | public Document getDocument() { | |
| 642 | 0 | return this; |
| 643 | } | |
| 644 | 0 | public void setDocument(Document document) {} //Null implementation - this makes no sense |
| 645 | ||
| 646 | @Override | |
| 647 | public boolean equals(Object obj) { | |
| 648 | 115292 | if (obj instanceof DocumentImpl) |
| 649 | 115292 | return this.getUid().equals(((DocumentImpl) obj).getUid()); |
| 650 | 0 | return false; |
| 651 | } | |
| 652 | ||
| 653 | public int compareTo(ModelObject o) { | |
| 654 | 0 | return (getUid().compareTo(o.getUid())); |
| 655 | } | |
| 656 | ||
| 657 | /** | |
| 658 | * Goes through all objects in the document and verifies that things are | |
| 659 | * looking correct. Returns null if things are good, or a string describing | |
| 660 | * the problems (and optionally steps to resolve) otherwise. | |
| 661 | */ | |
| 662 | public String doSanityChecks(){ | |
| 663 | 3034 | StringBuilder sb = new StringBuilder(); |
| 664 | 3034 | Logger logger = Logger.getLogger(this.getClass().getName()); |
| 665 | ||
| 666 | 3034 | List<ScheduledTransaction> stToDelete = new ArrayList<ScheduledTransaction>(); |
| 667 | 3034 | for (ScheduledTransaction st : getScheduledTransactions()){ |
| 668 | 0 | if (st.getTo() == null |
| 669 | || st.getFrom() == null){ | |
| 670 | ||
| 671 | 0 | String message = "The scheduled transaction " + st.getDescription() + " for amount " + st.getAmount() + " doesn't have the To and From sources set up correctly; deleting scheduled transaction."; |
| 672 | 0 | logger.warning(message); |
| 673 | 0 | sb.append(message).append("\n\n"); |
| 674 | 0 | stToDelete.add(st); |
| 675 | 0 | } |
| 676 | } | |
| 677 | 3034 | for (ScheduledTransaction st : stToDelete) { |
| 678 | try { | |
| 679 | 0 | removeScheduledTransaction(st); |
| 680 | } | |
| 681 | 0 | catch (ModelException me){ |
| 682 | 0 | String message = "Unable to delete scheduled transaction " + st.getDescription(); |
| 683 | 0 | logger.severe(message); |
| 684 | 0 | sb.append(message).append("\n\n"); |
| 685 | 0 | } |
| 686 | } | |
| 687 | ||
| 688 | 3034 | for (Transaction t : getTransactions()) { |
| 689 | try { | |
| 690 | 0 | if (t.getFrom() == null){ |
| 691 | 0 | Source s = null; |
| 692 | 0 | if (getAccounts().size() > 0) |
| 693 | 0 | s = getAccounts().get(0); |
| 694 | 0 | else if (getBudgetCategories().size() > 0) |
| 695 | 0 | s = getBudgetCategories().get(0); |
| 696 | else | |
| 697 | 0 | s = null; |
| 698 | 0 | String message = "Transaction with description '" + t.getDescription() + "' of amount '"+ t.getAmount() + "' on date '" + t.getDate() + "' does not have a From source defined. Setting this to '" + s.getFullName(); |
| 699 | 0 | logger.warning(message); |
| 700 | 0 | sb.append(message).append("\n\n"); |
| 701 | 0 | t.setFrom(s); |
| 702 | } | |
| 703 | ||
| 704 | 0 | if (t.getTo() == null){ |
| 705 | 0 | Source s = null; |
| 706 | 0 | if (getBudgetCategories().size() > 0) |
| 707 | 0 | s = getBudgetCategories().get(0); |
| 708 | 0 | else if (getAccounts().size() > 0) |
| 709 | 0 | s = getAccounts().get(0); |
| 710 | else | |
| 711 | 0 | s = null; |
| 712 | 0 | String message = "Transaction with description '" + t.getDescription() + "' of amount '"+ t.getAmount() + "' on date '" + t.getDate() + "' does not have a To source defined. Setting this to '" + s.getFullName(); |
| 713 | 0 | logger.warning(message); |
| 714 | 0 | sb.append(message).append("\n\n"); |
| 715 | 0 | t.setFrom(s); |
| 716 | } | |
| 717 | } | |
| 718 | 0 | catch (InvalidValueException ive){ |
| 719 | 0 | String message = "Error while correcting corrupted transaction: " + ive.getMessage(); |
| 720 | 0 | logger.severe(message); |
| 721 | 0 | sb.append(message).append("\n\n"); |
| 722 | 0 | } |
| 723 | } | |
| 724 | ||
| 725 | 3034 | if (sb.length() > 0) |
| 726 | 0 | return sb.toString(); |
| 727 | 3034 | return null; |
| 728 | } | |
| 729 | ||
| 730 | /** | |
| 731 | * Refreshes the UID map. This is a relatively expensive operation, and as such is | |
| 732 | * generally only done at file load time. | |
| 733 | * | |
| 734 | * We iterate through all objects for each data type, and add them to the UID map. | |
| 735 | * This will associate the object with their UID as the key in the Map. | |
| 736 | */ | |
| 737 | public void refreshUidMap() throws ModelException { | |
| 738 | 3034 | uidMap.clear(); |
| 739 | ||
| 740 | 3034 | for (Account a : getAccounts()) { |
| 741 | 0 | checkValid(a, false, true); |
| 742 | 0 | registerObjectInUidMap(a); |
| 743 | } | |
| 744 | ||
| 745 | 3034 | for (BudgetCategory bc : getBudgetCategories()) { |
| 746 | 30340 | checkValid(bc, false, true); |
| 747 | 30340 | registerObjectInUidMap(bc); |
| 748 | } | |
| 749 | ||
| 750 | // for (BudgetPeriodBean bpb : dataModel.getBudgetPeriods().values()) { | |
| 751 | // BudgetPeriod bp = new BudgetPeriod(this, bpb); | |
| 752 | // checkValid(bp, false, true); | |
| 753 | // registerObjectInUidMap(bp); | |
| 754 | // } | |
| 755 | ||
| 756 | 3034 | for (AccountType t : getAccountTypes()) { |
| 757 | 27306 | checkValid(t, false, true); |
| 758 | 27306 | registerObjectInUidMap(t); |
| 759 | } | |
| 760 | ||
| 761 | 3034 | for (Transaction t : getTransactions()) { |
| 762 | 0 | checkValid(t, false, true); |
| 763 | 0 | registerObjectInUidMap(t); |
| 764 | } | |
| 765 | ||
| 766 | 3034 | for (ScheduledTransaction s : getScheduledTransactions()) { |
| 767 | 0 | checkValid(s, false, true); |
| 768 | 0 | registerObjectInUidMap(s); |
| 769 | } | |
| 770 | 3034 | } |
| 771 | ||
| 772 | private void registerObjectInUidMap(ModelObject object){ | |
| 773 | 57646 | uidMap.put(object.getUid(), object); |
| 774 | 57646 | } |
| 775 | ||
| 776 | @Override | |
| 777 | public String toString() { | |
| 778 | 0 | StringBuilder sb = new StringBuilder(); |
| 779 | ||
| 780 | 0 | sb.append("\n--Accounts and Types--\n"); |
| 781 | 0 | for (AccountType t : getAccountTypes()) { |
| 782 | 0 | sb.append(t).append("\n"); |
| 783 | ||
| 784 | 0 | for (Account a : new FilteredLists.AccountListFilteredByType(this, this.getAccounts(), t)) { |
| 785 | 0 | sb.append("\t").append(a).append("\n"); |
| 786 | } | |
| 787 | } | |
| 788 | ||
| 789 | 0 | sb.append("\n--Budget Categories--\n"); |
| 790 | ||
| 791 | //We only show 3 levels here; if there are more, we figure that it is not needed to | |
| 792 | // output them in the toString method, as it is just for troubleshooting. | |
| 793 | 0 | for (BudgetCategory bc : new FilteredLists.BudgetCategoryListFilteredByParent(this, this.getBudgetCategories(), null)) { |
| 794 | 0 | sb.append(bc).append("\n"); |
| 795 | 0 | for (BudgetCategory child1 : new FilteredLists.BudgetCategoryListFilteredByParent(this, this.getBudgetCategories(), bc)) { |
| 796 | 0 | sb.append("\t").append(child1).append("\n"); |
| 797 | 0 | for (BudgetCategory child2 : new FilteredLists.BudgetCategoryListFilteredByParent(this, this.getBudgetCategories(), child1)) { |
| 798 | 0 | sb.append("\t\t").append(child2).append("\n"); |
| 799 | } | |
| 800 | } | |
| 801 | } | |
| 802 | ||
| 803 | // sb.append("\n--Budget Periods--\n"); | |
| 804 | // List<String> periodDates = new LinkedList<String>(dataModel.getBudgetPeriods().keySet()); | |
| 805 | // Collections.sort(periodDates); | |
| 806 | // for (String d : periodDates) { | |
| 807 | // sb.append(d).append(getBudgetPeriod(d)); | |
| 808 | // } | |
| 809 | 0 | sb.append("\n--Transactions--\n"); |
| 810 | 0 | sb.append("Total transactions: ").append(getTransactions().size()).append("\n"); |
| 811 | 0 | for (Transaction t : getTransactions()) { |
| 812 | 0 | sb.append(t.toString()).append("\n"); |
| 813 | } | |
| 814 | ||
| 815 | 0 | sb.append("\n--Scheduled Transactions--\n"); |
| 816 | 0 | for (ScheduledTransaction st : getScheduledTransactions()) { |
| 817 | 0 | sb.append(st.toString()).append("\n"); |
| 818 | } | |
| 819 | 0 | sb.append("--"); |
| 820 | ||
| 821 | 0 | return sb.toString(); |
| 822 | } | |
| 823 | ||
| 824 | public Time getModified() { | |
| 825 | 0 | return modifiedTime; |
| 826 | } | |
| 827 | ||
| 828 | private void checkLists(){ | |
| 829 | 324836 | if (this.accounts == null) |
| 830 | 0 | this.accounts = new LinkedList<Account>(); |
| 831 | 324836 | if (this.transactions == null) |
| 832 | 0 | this.transactions = new LinkedList<Transaction>(); |
| 833 | 324836 | if (this.scheduledTransactions == null) |
| 834 | 0 | this.scheduledTransactions = new LinkedList<ScheduledTransaction>(); |
| 835 | 324836 | if (this.accountTypes == null) |
| 836 | 0 | this.accountTypes = new LinkedList<AccountType>(); |
| 837 | 324836 | } |
| 838 | ||
| 839 | /** | |
| 840 | * Perform all the sanity checks here, for code simplicity | |
| 841 | * @param object The object to be modified | |
| 842 | * @param isAddOperation Is this an add operation? (This affects some of the checks, | |
| 843 | * such as if the UID is already entered into the model). | |
| 844 | * @param isUidRefresh Is this being called from the refreshUid method? | |
| 845 | */ | |
| 846 | private void checkValid(ModelObject object, boolean isAddOperation, boolean isUidRefresh) throws ModelException { | |
| 847 | 115292 | if (object.getDocument() == null) |
| 848 | 0 | throw new ModelException("Document has not yet been set for this object"); |
| 849 | ||
| 850 | 115292 | if(!object.getDocument().equals(this)) |
| 851 | 0 | throw new ModelException("Cannot modify an object not in this model"); |
| 852 | ||
| 853 | 115292 | if (isAddOperation){ |
| 854 | // if (this.getUid(object.getBean()) != null) | |
| 855 | // throw new DataModelProblemException("Cannot have the same UID for multiple objects in the model.", this); | |
| 856 | ||
| 857 | // if (object instanceof Account){ | |
| 858 | // for (Account a : getAccounts()) { | |
| 859 | // if (a.getName().equalsIgnoreCase(((Account) object).getName())) | |
| 860 | // throw new ModelException("Cannot have multiple accounts with the same name"); | |
| 861 | // } | |
| 862 | // } | |
| 863 | ||
| 864 | 57646 | if (object instanceof AccountType){ |
| 865 | 27306 | for (AccountType at : getAccountTypes()) { |
| 866 | 109224 | if (at.getName().equalsIgnoreCase(((AccountType) object).getName())) |
| 867 | 0 | throw new ModelException("Cannot have multiple accounts types with the same name"); |
| 868 | } | |
| 869 | } | |
| 870 | ||
| 871 | ||
| 872 | 57646 | if (object instanceof Transaction){ |
| 873 | 0 | Transaction t = (Transaction) object; |
| 874 | ||
| 875 | 0 | if (t.getFrom() instanceof Split && t.getFromSplits() != null){ |
| 876 | 0 | long splitSum = 0; |
| 877 | 0 | for (TransactionSplit split : t.getFromSplits()) { |
| 878 | 0 | splitSum += split.getAmount(); |
| 879 | 0 | if (split.getSource() == null) |
| 880 | 0 | throw new ModelException("Cannot have a null source within a TransactionSplit object."); |
| 881 | 0 | if (split.getAmount() == 0) |
| 882 | 0 | throw new ModelException("Cannot have an amount equal to zero within a TransactionSplit object."); |
| 883 | 0 | if (split.getSource() instanceof BudgetCategory && !((BudgetCategory) split.getSource()).isIncome()) |
| 884 | 0 | throw new ModelException("All Budget Category splits in the From position must be income categories."); |
| 885 | } | |
| 886 | ||
| 887 | 0 | if (splitSum != t.getAmount()) |
| 888 | 0 | throw new ModelException("The sum of the From splits do not equal the transaction amount"); |
| 889 | 0 | } |
| 890 | else { | |
| 891 | 0 | if (t.getFrom() instanceof BudgetCategory && !((BudgetCategory) t.getFrom()).isIncome()){ |
| 892 | 0 | throw new ModelException("Budget Categories in the From position must be income categories."); |
| 893 | } | |
| 894 | } | |
| 895 | ||
| 896 | 0 | if (t.getTo() instanceof Split && t.getToSplits() != null){ |
| 897 | 0 | long splitSum = 0; |
| 898 | 0 | for (TransactionSplit split : t.getToSplits()) { |
| 899 | 0 | splitSum += split.getAmount(); |
| 900 | 0 | if (split.getSource() == null) |
| 901 | 0 | throw new ModelException("Cannot have a null source within a TransactionSplit object."); |
| 902 | 0 | if (split.getAmount() == 0) |
| 903 | 0 | throw new ModelException("Cannot have an amount equal to zero within a TransactionSplit object."); |
| 904 | 0 | if (split.getSource() instanceof BudgetCategory && ((BudgetCategory) split.getSource()).isIncome()) |
| 905 | 0 | throw new ModelException("All Budget Category splits in the To position must be expense categories."); |
| 906 | } | |
| 907 | ||
| 908 | 0 | if (splitSum != t.getAmount()) |
| 909 | 0 | throw new ModelException("The sum of the To splits do not equal the transaction amount"); |
| 910 | 0 | } |
| 911 | else { | |
| 912 | 0 | if (t.getTo() instanceof BudgetCategory && ((BudgetCategory) t.getTo()).isIncome()){ |
| 913 | 0 | throw new ModelException("Budget Categories in the To position must be expense categories."); |
| 914 | } | |
| 915 | } | |
| 916 | ||
| 917 | } | |
| 918 | ||
| 919 | //We currently don't check for duplicate names. Some instances (such as Perfitrack plugin) may require duplicates; also, | |
| 920 | // other than the potential to be confused with two identical names, there is no problem having duplicates. | |
| 921 | // if (object instanceof BudgetCategory){ | |
| 922 | // for (BudgetCategory bc : getBudgetCategories()) { | |
| 923 | // //If the two budget categories are both children of the same node (which can be null), | |
| 924 | // // and the name is the same, throw an exception. | |
| 925 | // if (bc.getName().equalsIgnoreCase(((BudgetCategory) object).getName()) | |
| 926 | // && ((bc.getParent() == null && ((BudgetCategory) object).getParent() == null) | |
| 927 | // || (bc.getParent() != null && ((BudgetCategory) object).getParent() != null && bc.getParent().equals(((BudgetCategory) object).getParent())))) | |
| 928 | // throw new ModelException("Cannot have multiple budget categories with the same name as children of the same node"); | |
| 929 | // } | |
| 930 | // } | |
| 931 | ||
| 932 | 57646 | if (object instanceof ScheduledTransaction){ |
| 933 | 0 | for (ScheduledTransaction s : getScheduledTransactions()) { |
| 934 | 0 | if (s.getScheduleName().equalsIgnoreCase(((ScheduledTransaction) object).getScheduleName())) |
| 935 | 0 | throw new ModelException("Cannot have multiple scheduled transactions with the same name"); |
| 936 | } | |
| 937 | } | |
| 938 | } | |
| 939 | ||
| 940 | 115292 | if (isAddOperation || isUidRefresh){ |
| 941 | 115292 | if (uidMap.get(object.getUid()) != null) |
| 942 | 0 | throw new DataModelProblemException("Identical UID already in model! Model is probably corrupt!", this); |
| 943 | } | |
| 944 | ||
| 945 | 115292 | } |
| 946 | ||
| 947 | /** | |
| 948 | * Runs through the list of scheduled transactions, and adds any which | |
| 949 | * show be executed to the apropriate transacactions list. | |
| 950 | + * Checks for the frequency type and based on it finds if a transaction is scheduled for a date | |
| 951 | + * that has gone past. | |
| 952 | */ | |
| 953 | public void updateScheduledTransactions(){ | |
| 954 | 0 | updateScheduledTransactions(new Date()); |
| 955 | 0 | } |
| 956 | ||
| 957 | /** | |
| 958 | * Runs through the list of scheduled transactions, and adds any which | |
| 959 | * show be executed to the appropriate transactions list. | |
| 960 | * Checks for the frequency type and based on it finds if a transaction is scheduled for a date | |
| 961 | * that has gone past. | |
| 962 | * | |
| 963 | * This method includes an argument to specify what the current date is. This can | |
| 964 | * be useful if you want to add transactions after the current date. | |
| 965 | */ | |
| 966 | public void updateScheduledTransactions(Date currentDate){ | |
| 967 | 0 | startBatchChange(); |
| 968 | ||
| 969 | //Update any scheduled transactions | |
| 970 | 0 | final Date today = DateUtil.getEndOfDay(currentDate); |
| 971 | //We specify a GregorianCalendar because we make some assumptions | |
| 972 | // about numbering of months, etc that may break if we | |
| 973 | // use the default calendar for the locale. It's not the | |
| 974 | // prettiest code, but it works. Perhaps we can change | |
| 975 | // it to be cleaner later on... | |
| 976 | 0 | final GregorianCalendar tempCal = new GregorianCalendar(); |
| 977 | ||
| 978 | 0 | for (ScheduledTransaction s : new FilteredLists.ScheduledTransactionListFilteredByBeforeToday(this, getScheduledTransactions())) { |
| 979 | 0 | if (Const.DEVEL) logger.info("Looking at scheduled transaction " + s.getScheduleName()); |
| 980 | ||
| 981 | 0 | Date tempDate = s.getLastDayCreated(); |
| 982 | //#1779286 Bug BiWeekly Scheduled Transactions -Check if this transaction has never been created. | |
| 983 | 0 | boolean isNewTransaction=false; |
| 984 | //The lastDayCreated need to date as such without rolling forward by a day and the start fo the day | |
| 985 | //so calculations of difference of days are on the same keel as tempDate. | |
| 986 | 0 | Date lastDayCreated = null; |
| 987 | //Temp date is where we will start looping from. | |
| 988 | 0 | if (tempDate == null){ |
| 989 | //If it is null, we need to init it to a sane value. | |
| 990 | 0 | tempDate = s.getStartDate(); |
| 991 | 0 | isNewTransaction=true; |
| 992 | //The below is just to avoid NPE's; ideally changing the order | |
| 993 | // of the checking below will solve the problem, but better safe than sorry. | |
| 994 | //The reason we set this date to an impossibly early date is to ensure | |
| 995 | // that we include a scheduled transaction on the first day that matches, | |
| 996 | // even if that day is the first day of any scheduled transactions. | |
| 997 | 0 | lastDayCreated=DateUtil.getDate(1900); |
| 998 | } | |
| 999 | else { | |
| 1000 | 0 | lastDayCreated = DateUtil.getStartOfDay(tempDate); |
| 1001 | //We start one day after the last day, to avoid repeats. | |
| 1002 | // See bug #1641937 for more details. | |
| 1003 | 0 | tempDate = DateUtil.addDays(tempDate, 1); |
| 1004 | ||
| 1005 | } | |
| 1006 | ||
| 1007 | ||
| 1008 | ||
| 1009 | 0 | tempDate = DateUtil.getStartOfDay(tempDate); |
| 1010 | ||
| 1011 | 0 | if (Const.DEVEL){ |
| 1012 | 0 | logger.finest("tempDate = " + tempDate); |
| 1013 | 0 | logger.finest("startDate = " + s.getStartDate()); |
| 1014 | 0 | logger.finest("endDate = " + s.getEndDate()); |
| 1015 | } | |
| 1016 | ||
| 1017 | //The transaction is scheduled for a date before today and before the EndDate | |
| 1018 | while (tempDate.before(today) | |
| 1019 | 0 | && (s.getEndDate() == null |
| 1020 | || s.getEndDate().after(tempDate) | |
| 1021 | || (DateUtil.getDaysBetween(s.getEndDate(), tempDate, false) == 0))) { | |
| 1022 | 0 | if (Const.DEVEL) logger.finest("Trying date " + tempDate); |
| 1023 | ||
| 1024 | //We use a Calendar instead of a Date object for comparisons | |
| 1025 | // because the Calendar interface is much nicer. | |
| 1026 | 0 | tempCal.setTime(tempDate); |
| 1027 | ||
| 1028 | 0 | boolean todayIsTheDay = false; |
| 1029 | ||
| 1030 | //We check each type of schedule, and if it matches, | |
| 1031 | // we set todayIsTheDay to true. We could do it | |
| 1032 | // all in one huge if statement, but that is very | |
| 1033 | // hard to read and maintain. | |
| 1034 | ||
| 1035 | //If we are using the Monthly by Date frequency, | |
| 1036 | // we only check if the given day is equal to the | |
| 1037 | // scheduled day. | |
| 1038 | 0 | if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_MONTHLY_BY_DATE.toString()) |
| 1039 | && (s.getScheduleDay() == tempCal.get(Calendar.DAY_OF_MONTH) | |
| 1040 | || (s.getScheduleDay() == 32 //Position 32 is 'Last Day of Month'. ScheduleFrequencyDayOfMonth.SCHEDULE_DATE_LAST_DAY.ordinal() + 1 | |
| 1041 | && tempCal.get(Calendar.DAY_OF_MONTH) == tempCal.getActualMaximum(Calendar.DAY_OF_MONTH)))){ | |
| 1042 | ||
| 1043 | 0 | todayIsTheDay = true; |
| 1044 | } | |
| 1045 | //If we are using the Monthly by Day of Week, | |
| 1046 | // we check if the given day (Sunday, Monday, etc) is equal to the | |
| 1047 | // scheduleDay, and if the given day is within the first week. | |
| 1048 | // FYI, we store Sunday == 0, even though Calendar.SUNDAY == 1. Thus, | |
| 1049 | // we add 1 to our stored day before comparing it. | |
| 1050 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_MONTHLY_BY_DAY_OF_WEEK.toString()) |
| 1051 | && s.getScheduleDay() + 1 == tempCal.get(Calendar.DAY_OF_WEEK) | |
| 1052 | && tempCal.get(Calendar.DAY_OF_MONTH) <= 7){ | |
| 1053 | 0 | todayIsTheDay = true; |
| 1054 | } | |
| 1055 | //If we are using Weekly frequency, we only need to compare | |
| 1056 | // the number of the day. | |
| 1057 | // FYI, we store Sunday == 0, even though Calendar.SUNDAY == 1. Thus, | |
| 1058 | // we add 1 to our stored day before comparing it. | |
| 1059 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_WEEKLY.toString()) |
| 1060 | && s.getScheduleDay() + 1 == tempCal.get(Calendar.DAY_OF_WEEK)){ | |
| 1061 | 0 | todayIsTheDay = true; |
| 1062 | } | |
| 1063 | //If we are using BiWeekly frequency, we need to compare | |
| 1064 | // the number of the day as well as ensure that there is one | |
| 1065 | // week between each scheduled transaction. | |
| 1066 | // FYI, we store Sunday == 0, even though Calendar.SUNDAY == 1. Thus, | |
| 1067 | // we add 1 to our stored day before comparing it. | |
| 1068 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_BIWEEKLY.toString()) |
| 1069 | && s.getScheduleDay() + 1 == tempCal.get(Calendar.DAY_OF_WEEK) | |
| 1070 | //As tempdate has been moved forward by one day we need to check if it is >= 13 instead of >13 | |
| 1071 | && ((DateUtil.getDaysBetween(lastDayCreated, tempDate, false) >= 13) | |
| 1072 | || isNewTransaction)){ | |
| 1073 | 0 | todayIsTheDay = true; |
| 1074 | 0 | lastDayCreated = (Date) tempDate.clone(); |
| 1075 | 0 | if(isNewTransaction){ |
| 1076 | 0 | isNewTransaction=false; |
| 1077 | } | |
| 1078 | } | |
| 1079 | //Every X days, where X is the value in s.getScheduleDay(). Check if we | |
| 1080 | // have passed the correct number of days since the last transaction. | |
| 1081 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_EVERY_X_DAYS.toString()) |
| 1082 | && DateUtil.getDaysBetween(lastDayCreated, tempDate, false) >= s.getScheduleDay() ){ | |
| 1083 | 0 | todayIsTheDay = true; |
| 1084 | 0 | lastDayCreated = (Date) tempDate.clone(); |
| 1085 | } | |
| 1086 | //Every day - it's obvious enough even for a monkey! | |
| 1087 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_EVERY_DAY.toString())){ |
| 1088 | 0 | todayIsTheDay = true; |
| 1089 | } | |
| 1090 | //Every weekday - all days but Saturday and Sunday. | |
| 1091 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_EVERY_WEEKDAY.toString()) |
| 1092 | && (tempCal.get(Calendar.DAY_OF_WEEK) < Calendar.SATURDAY) | |
| 1093 | && (tempCal.get(Calendar.DAY_OF_WEEK) > Calendar.SUNDAY)){ | |
| 1094 | 0 | todayIsTheDay = true; |
| 1095 | } | |
| 1096 | //To make this one clearer, we do it in two passes. | |
| 1097 | // First, we check the frequency type and the day. | |
| 1098 | // If these match, we do our bit bashing to determine | |
| 1099 | // if the week is correct. | |
| 1100 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_MULTIPLE_WEEKS_EVERY_MONTH.toString()) |
| 1101 | && s.getScheduleDay() + 1 == tempCal.get(Calendar.DAY_OF_WEEK)){ | |
| 1102 | 0 | if (Const.DEVEL) { |
| 1103 | 0 | logger.finest("We are looking at day " + tempCal.get(Calendar.DAY_OF_WEEK) + ", which matches s.getScheduleDay() which == " + s.getScheduleDay()); |
| 1104 | 0 | logger.finest("s.getScheduleWeek() == " + s.getScheduleWeek()); |
| 1105 | } | |
| 1106 | 0 | int week = s.getScheduleWeek(); |
| 1107 | //The week mask should return 1 for the first week (day 1 - 7), | |
| 1108 | // 2 for the second week (day 8 - 14), 4 for the third week (day 15 - 21), | |
| 1109 | // and 8 for the fourth week (day 22 - 28). We then AND it with | |
| 1110 | // the scheduleWeek to determine if this week matches the criteria | |
| 1111 | // or not. | |
| 1112 | 0 | int weekNumber = tempCal.get(Calendar.DAY_OF_WEEK_IN_MONTH) - 1; |
| 1113 | 0 | int weekMask = (int) Math.pow(2, weekNumber); |
| 1114 | 0 | if (Const.DEVEL){ |
| 1115 | 0 | logger.finest("The week number is " + weekNumber + ", the week mask is " + weekMask + ", and the day of week in month is " + tempCal.get(Calendar.DAY_OF_WEEK_IN_MONTH)); |
| 1116 | } | |
| 1117 | 0 | if ((week & weekMask) != 0){ |
| 1118 | 0 | if (Const.DEVEL) logger.info("The date " + tempCal.getTime() + " matches the requirements."); |
| 1119 | 0 | todayIsTheDay = true; |
| 1120 | } | |
| 1121 | 0 | } |
| 1122 | //To make this one clearer, we do it in two passes. | |
| 1123 | // First, we check the frequency type and the day. | |
| 1124 | // If these match, we do our bit bashing to determine | |
| 1125 | // if the month is correct. | |
| 1126 | 0 | else if (s.getFrequencyType().equals(ScheduleFrequency.SCHEDULE_FREQUENCY_MULTIPLE_MONTHS_EVERY_YEAR.toString()) |
| 1127 | && s.getScheduleDay() == tempCal.get(Calendar.DAY_OF_MONTH)){ | |
| 1128 | 0 | int months = s.getScheduleMonth(); |
| 1129 | //The month mask should be 2 ^ MONTH NUMBER, | |
| 1130 | // where January == 0. | |
| 1131 | // i.e. 1 for January, 4 for March, 2048 for December. | |
| 1132 | 0 | int monthMask = (int) Math.pow(2, tempCal.get(Calendar.MONTH)); |
| 1133 | 0 | if ((months & monthMask) != 0){ |
| 1134 | 0 | if (Const.DEVEL) logger.info("The date " + tempCal.getTime() + " matches the requirements."); |
| 1135 | 0 | todayIsTheDay = true; |
| 1136 | } | |
| 1137 | } | |
| 1138 | ||
| 1139 | //Check that there has not already been a scheduled transaction with identical | |
| 1140 | // paramters for this day. This is in response to a potential bug where | |
| 1141 | // the last scheduled day is missing (happened once in development | |
| 1142 | // version, but may not be a repeating problem). | |
| 1143 | //This has the potential to skip scheduled transactions, if there | |
| 1144 | // are multiple scheduled transactions which go to and from the | |
| 1145 | // same accounts / categories on the same day. If this proves to | |
| 1146 | // be a problem, we may make the checks more specific. | |
| 1147 | 0 | if (todayIsTheDay){ |
| 1148 | 0 | for (Transaction t : getTransactions(tempDate, tempDate)) { |
| 1149 | 0 | if (DateUtil.isSameDay(t.getDate(), tempDate) |
| 1150 | && t.isScheduled() | |
| 1151 | && t.getFrom().equals(s.getFrom()) | |
| 1152 | && t.getTo().equals(s.getTo()) | |
| 1153 | && t.getDescription().equals(s.getDescription())){ | |
| 1154 | 0 | todayIsTheDay = false; |
| 1155 | try { | |
| 1156 | 0 | s.setLastDayCreated(tempDate); |
| 1157 | } | |
| 1158 | 0 | catch (InvalidValueException ive){ |
| 1159 | 0 | logger.warning("Error setting last created date"); |
| 1160 | 0 | } |
| 1161 | } | |
| 1162 | } | |
| 1163 | } | |
| 1164 | ||
| 1165 | ||
| 1166 | try { | |
| 1167 | //If one of the above rules matches, we will copy the | |
| 1168 | // scheduled transaction into the transactions list | |
| 1169 | // at the given day. | |
| 1170 | 0 | if (todayIsTheDay){ |
| 1171 | ||
| 1172 | 0 | if (Const.DEVEL) logger.finest("Setting last created date to " + tempDate); |
| 1173 | 0 | s.setLastDayCreated(DateUtil.getEndOfDay(tempDate)); |
| 1174 | 0 | if (Const.DEVEL) logger.finest("Last created date to " + s.getLastDayCreated()); |
| 1175 | ||
| 1176 | 0 | if (s.getMessage() != null && s.getMessage().trim().length() > 0){ |
| 1177 | 0 | String[] options = new String[1]; |
| 1178 | 0 | options[0] = TextFormatter.getTranslation(ButtonKeys.BUTTON_OK); |
| 1179 | ||
| 1180 | 0 | JOptionPane.showOptionDialog( |
| 1181 | null, | |
| 1182 | s.getMessage(), | |
| 1183 | TextFormatter.getTranslation(BuddiKeys.SCHEDULED_MESSAGE), | |
| 1184 | JOptionPane.DEFAULT_OPTION, | |
| 1185 | JOptionPane.INFORMATION_MESSAGE, | |
| 1186 | null, | |
| 1187 | options, | |
| 1188 | options[0] | |
| 1189 | ); | |
| 1190 | } | |
| 1191 | ||
| 1192 | 0 | if (tempDate != null |
| 1193 | && s.getDescription() != null) { | |
| 1194 | 0 | Transaction t = ModelFactory.createTransaction( |
| 1195 | tempDate, | |
| 1196 | s.getDescription(), | |
| 1197 | s.getAmount(), | |
| 1198 | s.getFrom(), | |
| 1199 | s.getTo()); | |
| 1200 | ||
| 1201 | 0 | t.setDate(tempDate); |
| 1202 | 0 | t.setDescription(s.getDescription()); |
| 1203 | 0 | t.setAmount(s.getAmount()); |
| 1204 | 0 | t.setTo(s.getTo()); |
| 1205 | 0 | t.setFrom(s.getFrom()); |
| 1206 | 0 | t.setMemo(s.getMemo()); |
| 1207 | 0 | t.setNumber(s.getNumber()); |
| 1208 | // t.setCleared(s.isCleared()); | |
| 1209 | // t.setReconciled(s.isReconciled()); | |
| 1210 | 0 | t.setScheduled(true); |
| 1211 | ||
| 1212 | 0 | this.addTransaction(t); |
| 1213 | 0 | if (Const.DEVEL) logger.info("Added scheduled transaction " + t + " to transaction list on date " + t.getDate()); |
| 1214 | 0 | } |
| 1215 | } | |
| 1216 | else { | |
| 1217 | 0 | logger.finest("Today was not the day to add the scheduled transaction...\n\tDate = " |
| 1218 | + tempDate | |
| 1219 | + "\n\tDescription = " | |
| 1220 | + s.getDescription()); | |
| 1221 | } | |
| 1222 | ||
| 1223 | } | |
| 1224 | 0 | catch (ModelException me){ |
| 1225 | 0 | logger.log(Level.WARNING, "Error adding scheduled tranaction", me); |
| 1226 | 0 | } |
| 1227 | ||
| 1228 | 0 | tempDate = DateUtil.addDays(tempDate, 1); |
| 1229 | 0 | } |
| 1230 | 0 | } |
| 1231 | ||
| 1232 | 0 | finishBatchChange(); |
| 1233 | 0 | updateAllBalances(); |
| 1234 | 0 | } |
| 1235 | ||
| 1236 | public void setPassword(char[] password) { | |
| 1237 | 0 | this.password = password; |
| 1238 | 0 | } |
| 1239 | ||
| 1240 | public long getNetWorth(Date date) { | |
| 1241 | 15154 | List<Account> accounts = getAccounts(); |
| 1242 | 15154 | long total = 0; |
| 1243 | ||
| 1244 | 15154 | for (Account a : accounts) { |
| 1245 | 0 | if (!a.isDeleted()){ |
| 1246 | 0 | if (date == null) |
| 1247 | 0 | total += a.getBalance(); |
| 1248 | else | |
| 1249 | 0 | total += a.getBalance(date); |
| 1250 | } | |
| 1251 | } | |
| 1252 | ||
| 1253 | 15154 | return total; |
| 1254 | } | |
| 1255 | ||
| 1256 | ||
| 1257 | /** | |
| 1258 | * Performs a deep clone of the Document model. This will result in a completely | |
| 1259 | * different object, with all component objects different, but with all the same | |
| 1260 | * values. Immutable objects (such as Strings) and primitives may be identical | |
| 1261 | * between cloned and source objects, but all immutable objects will not be identical. | |
| 1262 | * @param source | |
| 1263 | * @return | |
| 1264 | */ | |
| 1265 | public Document clone() throws CloneNotSupportedException { | |
| 1266 | 0 | updateAllBalances(); //We want to be sure that out original object is in the correct state before cloning. |
| 1267 | ||
| 1268 | try { | |
| 1269 | 0 | Map<ModelObject, ModelObject> originalToClonedObjectMap = new HashMap<ModelObject, ModelObject>(); |
| 1270 | ||
| 1271 | 0 | DocumentImpl clone = new DocumentImpl(); |
| 1272 | 0 | clone.startBatchChange(); |
| 1273 | 0 | clone.setFile(this.getFile()); |
| 1274 | 0 | clone.flags = flags; |
| 1275 | 0 | clone.modifiedTime = new Time(modifiedTime); |
| 1276 | 0 | if (password != null) |
| 1277 | 0 | clone.password = new String(password).toCharArray(); |
| 1278 | ||
| 1279 | 0 | originalToClonedObjectMap.put(this, clone); |
| 1280 | ||
| 1281 | //Clone all component objects. | |
| 1282 | 0 | for (AccountType old : this.getAccountTypes()) { |
| 1283 | 0 | clone.addAccountType((AccountType) ((AccountTypeImpl) old).clone(originalToClonedObjectMap)); |
| 1284 | } | |
| 1285 | 0 | for (Account old : this.getAccounts()) { |
| 1286 | 0 | clone.addAccount((Account) ((AccountImpl) old).clone(originalToClonedObjectMap)); |
| 1287 | } | |
| 1288 | 0 | for (BudgetCategory old : this.getBudgetCategories()) { |
| 1289 | 0 | clone.addBudgetCategory((BudgetCategory) ((BudgetCategoryImpl) old).clone(originalToClonedObjectMap)); |
| 1290 | } | |
| 1291 | 0 | for (ScheduledTransaction old : this.getScheduledTransactions()) { |
| 1292 | 0 | clone.addScheduledTransaction((ScheduledTransaction) ((ScheduledTransactionImpl) old).clone(originalToClonedObjectMap)); |
| 1293 | } | |
| 1294 | 0 | for (Transaction old : this.getTransactions()) { |
| 1295 | 0 | clone.addTransaction((Transaction) ((TransactionImpl) old).clone(originalToClonedObjectMap)); |
| 1296 | } | |
| 1297 | ||
| 1298 | 0 | clone.refreshUidMap(); |
| 1299 | 0 | clone.finishBatchChange(); |
| 1300 | ||
| 1301 | 0 | return clone; |
| 1302 | } | |
| 1303 | 0 | catch (ModelException me){ |
| 1304 | 0 | throw new CloneNotSupportedException(me.getMessage()); |
| 1305 | } | |
| 1306 | } | |
| 1307 | } |