history.js (13836B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 Zotero.History = new function(){ 27 this.begin = begin; 28 this.setAssociatedID = setAssociatedID; 29 this.add = add; 30 this.modify = modify; 31 this.remove = remove; 32 this.commit = commit; 33 this.cancel = cancel; 34 this.getPreviousEvent = getPreviousEvent; 35 this.getNextEvent = getNextEvent; 36 this.undo = undo; 37 this.redo = redo; 38 this.clear = clear; 39 this.clearAfter = clearAfter; 40 41 var _firstTime = true; 42 var _currentID = 0; 43 var _activeID; 44 var _activeEvent; 45 var _maxID = 0; 46 47 48 /** 49 * Begin a transaction set 50 * 51 * event: 'item-add', 'item-delete', 'item-modify', 'collection-add', 52 * 'collection-modify', 'collection-delete'... 53 * 54 * id: An id or array of ids that will be passed to 55 * Zotero.Notifier.trigger() on an undo or redo 56 **/ 57 function begin(event, id){ 58 if (_activeID){ 59 throw('History transaction set already in progress'); 60 } 61 62 // If running for the first time this session or we're in the middle of 63 // the history, clear any transaction sets after the current position 64 if (_firstTime || _currentID<_maxID){ 65 _firstTime = false; 66 this.clearAfter(); 67 } 68 69 Zotero.debug('Beginning history transaction set ' + event); 70 var sql = "INSERT INTO transactionSets (event, id) VALUES " 71 + "('" + event + "', "; 72 if (!id){ 73 sql += '0'; 74 } 75 // If array, insert hyphen-delimited string 76 else if (typeof id=='object'){ 77 sql += "'" + id.join('-') + "'" 78 } 79 else { 80 sql += id; 81 } 82 sql += ")"; 83 84 Zotero.DB.beginTransaction(); 85 _activeID = Zotero.DB.query(sql); 86 _activeEvent = event; 87 } 88 89 90 /** 91 * Associate an id or array of ids with the transaction set -- 92 * for use if the ids weren't available at when begin() was called 93 * 94 * id: An id or array of ids that will be passed to 95 * Zotero.Notifier.trigger() on an undo or redo 96 **/ 97 function setAssociatedID(id){ 98 if (!_activeID){ 99 throw('Cannot call setAssociatedID() with no history transaction set in progress'); 100 } 101 102 var sql = "UPDATE transactionSets SET id="; 103 if (!id){ 104 sql += '0'; 105 } 106 // If array, insert hyphen-delimited string 107 else if (typeof id=='object'){ 108 sql += "'" + id.join('-') + "'" 109 } 110 else { 111 sql += id; 112 } 113 sql += " WHERE transactionSetID=" + _activeID; 114 Zotero.DB.query(sql); 115 } 116 117 118 /** 119 * Add an add transaction to the current set 120 * 121 * Can be called before or after an INSERT statement 122 * 123 * key is a hyphen-delimited list of columns identifying the row 124 * e.g. 'itemID-creatorID' 125 * 126 * keyValues is a hyphen-delimited list of values matching the key parts 127 * e.g. '1-1' 128 **/ 129 function add(table, key, keyValues){ 130 return _addTransaction('add', table, key, keyValues); 131 } 132 133 134 /** 135 * Add a modify transaction to the current set 136 * 137 * Must be called before an UPDATE statement 138 * 139 * key is a hyphen-delimited list of columns identifying the row 140 * e.g. 'itemID-creatorID' 141 * 142 * keyValues is an array or hyphen-delimited string of values matching 143 * the key parts (e.g. [1, 1] or '1-1') 144 * 145 * _field_ is optional -- otherwise all fields are saved 146 **/ 147 function modify(table, key, keyValues, field){ 148 return _addTransaction('modify', table, key, keyValues, field); 149 } 150 151 152 /** 153 * Add a remove transaction to the current set 154 * 155 * Must be called before a DELETE statement 156 * 157 * key is a hyphen-delimited list of columns identifying the row 158 * e.g. 'itemID-creatorID' 159 * 160 * keyValues is a hyphen-delimited list of values matching the key parts 161 * e.g. '1-1' 162 **/ 163 function remove(table, key, keyValues){ 164 return _addTransaction('remove', table, key, keyValues); 165 } 166 167 168 /** 169 * Commit the current transaction set 170 **/ 171 function commit(){ 172 Zotero.debug('Committing history transaction set ' + _activeEvent); 173 Zotero.DB.commitTransaction(); 174 _currentID = _activeID; 175 _maxID = _activeID; 176 _activeID = null; 177 _activeEvent = null; 178 } 179 180 181 /** 182 * Cancel the current transaction set 183 **/ 184 function cancel(){ 185 Zotero.debug('Cancelling history transaction set ' + _activeEvent); 186 Zotero.DB.rollbackTransaction(); 187 _activeID = null; 188 _activeEvent = null; 189 } 190 191 192 /** 193 * Get the next event to undo, or false if none 194 **/ 195 function getPreviousEvent(){ 196 if (!_currentID){ 197 return false; 198 } 199 200 var sql = "SELECT event FROM transactionSets WHERE transactionSetID=" 201 + _currentID; 202 return Zotero.DB.valueQuery(sql); 203 } 204 205 206 /** 207 * Get the next event to redo, or false if none 208 **/ 209 function getNextEvent(){ 210 var sql = "SELECT event FROM transactionSets WHERE transactionSetID=" 211 + (_currentID + 1); 212 return Zotero.DB.valueQuery(sql); 213 } 214 215 216 /** 217 * Undo the last transaction set 218 **/ 219 function undo(){ 220 if (!_currentID){ 221 throw('No transaction set to undo'); 222 return false; 223 } 224 225 var id = _currentID; 226 Zotero.debug('Undoing transaction set ' + id); 227 Zotero.DB.beginTransaction(); 228 var undone = _do('undo'); 229 _currentID--; 230 Zotero.DB.commitTransaction(); 231 _reloadAndNotify(id); 232 return true; 233 } 234 235 236 /** 237 * Redo the next transaction set 238 **/ 239 function redo(){ 240 var id = _currentID + 1; 241 Zotero.debug('Redoing transaction set ' + id); 242 Zotero.DB.beginTransaction(); 243 var redone = _do('redo'); 244 _currentID++; 245 Zotero.DB.commitTransaction(); 246 _reloadAndNotify(id, true); 247 return redone; 248 } 249 250 251 /** 252 * Clear the entire history 253 **/ 254 function clear(){ 255 Zotero.DB.beginTransaction(); 256 Zotero.DB.query("DELETE FROM transactionSets"); 257 Zotero.DB.query("DELETE FROM transactions"); 258 Zotero.DB.query("DELETE FROM transactionLog"); 259 _currentID = null; 260 _activeID = null; 261 _activeEvent = null; 262 _maxID = null; 263 Zotero.DB.commitTransaction(); 264 } 265 266 267 /** 268 * Clear all transactions in history after the current one 269 **/ 270 function clearAfter(){ 271 Zotero.DB.beginTransaction(); 272 var min = Zotero.DB.valueQuery("SELECT MIN(transactionID) FROM " 273 + "transactions WHERE transactionSetID=" + (_currentID + 1)); 274 275 if (!min){ 276 Zotero.DB.commitTransaction(); 277 return; 278 } 279 280 Zotero.DB.query("DELETE FROM transactionLog " 281 + "WHERE transactionID>=" + min); 282 Zotero.DB.query("DELETE FROM transactions " 283 + "WHERE transactionID>=" + min); 284 Zotero.DB.query("DELETE FROM transactionSets " 285 + "WHERE transactionSetID>" + _currentID); 286 287 _maxID = _currentID; 288 _activeID = null; 289 Zotero.DB.commitTransaction(); 290 return; 291 } 292 293 294 // 295 // Private methods 296 // 297 298 function _addTransaction(action, table, key, keyValues, field){ 299 if (!_activeID){ 300 throw('Cannot add history transaction with no transaction set in progress'); 301 } 302 303 if (typeof keyValues == 'object'){ 304 keyValues = keyValues.join('-'); 305 } 306 307 var contextString = table + '.' + key + '.' + keyValues; 308 var context = _parseContext(contextString); 309 var fromClause = _contextToSQLFrom(context); 310 311 var sql = "INSERT INTO transactions (transactionSetID, context, action) " 312 + "VALUES (" + _activeID + ", '" + contextString 313 + "', '" + action + "')"; 314 315 var transactionID = Zotero.DB.query(sql); 316 317 switch (action){ 318 case 'add': 319 // No need to store an add, since we'll just delete it to reverse 320 break; 321 case 'modify': 322 // Only save one field -- _do() won't know about this, but the 323 // UPDATE statements on the other fields just won't do anything 324 if (field){ 325 var sql = "INSERT INTO transactionLog SELECT " + transactionID 326 + ", '" + field + "', " + field + fromClause; 327 Zotero.DB.query(sql); 328 break; 329 } 330 // Fall through if no field specified and save all 331 case 'remove': 332 var cols = Zotero.DB.getColumns(table); 333 for (var i in cols){ 334 // If column is not part of the key, log it 335 if (!context['keys'].indexOf(cols[i]) === -1){ 336 var sql = "INSERT INTO transactionLog " 337 + "SELECT " + transactionID + ", '" + cols[i] 338 + "', " + cols[i] + fromClause; 339 Zotero.DB.query(sql); 340 } 341 } 342 break; 343 default: 344 Zotero.DB.rollbackTransaction(); 345 throw("Invalid history action '" + action + "'"); 346 } 347 } 348 349 350 function _do(mode){ 351 switch (mode){ 352 case 'undo': 353 var id = _currentID; 354 break; 355 case 'redo': 356 var id = _currentID + 1; 357 break; 358 } 359 360 var sql = "SELECT transactionID, context, action FROM transactions " 361 + "WHERE transactionSetID=" + id; 362 var transactions = Zotero.DB.query(sql); 363 364 if (!transactions){ 365 throw('Transaction set not found for ' 366 + (mode=='undo' ? 'current' : 'next') + id); 367 } 368 369 for (var i in transactions){ 370 var transactionID = transactions[i]['transactionID']; 371 var context = _parseContext(transactions[i]['context']); 372 373 // If in redo mode, swap 'add' and 'remove' 374 if (mode=='redo'){ 375 switch (transactions[i]['action']){ 376 case 'add': 377 transactions[i]['action'] = 'remove'; 378 break; 379 case 'remove': 380 transactions[i]['action'] = 'add'; 381 break; 382 } 383 } 384 385 switch (transactions[i]['action']){ 386 case 'add': 387 var fromClause = _contextToSQLFrom(context); 388 389 // First, store the row we're about to delete for later redo 390 var cols = Zotero.DB.getColumns(context['table']); 391 for (var i in cols){ 392 // If column is not part of the key, log it 393 if (!context['keys'].indexOf(cols[i]) === -1){ 394 var sql = "INSERT INTO transactionLog " 395 + "SELECT " + transactionID + ", '" + cols[i] 396 + "', " + cols[i] + fromClause; 397 Zotero.DB.query(sql); 398 } 399 } 400 401 // And delete the row 402 var sql = "DELETE" + fromClause; 403 Zotero.DB.query(sql); 404 break; 405 406 case 'modify': 407 // Retrieve old values 408 var sql = "SELECT field, value FROM transactionLog " 409 + "WHERE transactionID=" + transactionID; 410 var oldFieldValues = Zotero.DB.query(sql); 411 412 // Retrieve new values 413 var sql = "SELECT *" + _contextToSQLFrom(context); 414 var newValues = Zotero.DB.rowQuery(sql); 415 416 // Update row with old values 417 var sql = "UPDATE " + context['table'] + " SET "; 418 var values = []; 419 for (var i in oldFieldValues){ 420 sql += oldFieldValues[i]['field'] + '=?, '; 421 values.push(oldFieldValues[i]['value']); 422 } 423 sql = sql.substr(0, sql.length-2) + _contextToSQLWhere(context); 424 Zotero.DB.query(sql, values); 425 426 // Update log with new values for later redo 427 for (var i in newValues){ 428 if (context['keys'].indexOf(i) === -1){ 429 var sql = "UPDATE transactionLog SET " 430 + "value=? WHERE transactionID=? AND field=?"; 431 Zotero.DB.query(sql, [i, newValues[i], transactionID]); 432 } 433 } 434 break; 435 436 case 'remove': 437 // Retrieve old values 438 var sql = "SELECT field, value FROM transactionLog " 439 + "WHERE transactionID=" + transactionID; 440 var oldFieldValues = Zotero.DB.query(sql); 441 442 // Add key to parameters 443 var fields = [], values = [], marks = []; 444 for (var i=0; i<context['keys'].length; i++){ 445 fields.push(context['keys'][i]); 446 values.push(context['values'][i]); 447 marks.push('?'); 448 } 449 450 // Add other fields to parameters 451 for (var i in oldFieldValues){ 452 fields.push(oldFieldValues[i]['field']); 453 values.push(oldFieldValues[i]['value']); 454 marks.push('?'); 455 } 456 457 // Insert old values into table 458 var sql = "INSERT INTO " + context['table'] + "(" 459 + fields.join() + ") VALUES (" + marks.join() + ")"; 460 Zotero.DB.query(sql, values); 461 462 // Delete restored data from transactionLog 463 var sql = "DELETE FROM transactionLog WHERE transactionID=" 464 + transactionID; 465 Zotero.DB.query(sql); 466 break; 467 } 468 } 469 } 470 471 472 function _parseContext(context){ 473 var parts = context.split('.'); 474 var parsed = { 475 table:parts[0], 476 keys:parts[1].split('-'), 477 values:parts[2].split('-') 478 } 479 if (parsed['keys'].length!=parsed['values'].length){ 480 throw("Different number of keys and values in _parseContext('" 481 + context + "')"); 482 } 483 484 return parsed; 485 } 486 487 488 function _contextToSQLFrom(parsed){ 489 return " FROM " + parsed['table'] + _contextToSQLWhere(parsed); 490 } 491 492 493 function _contextToSQLWhere(parsed){ 494 var sql = " WHERE "; 495 for (var i=0; i<parsed['keys'].length; i++){ 496 // DEBUG: type? 497 sql += parsed['keys'][i] + "='" + parsed['values'][i] + "' AND "; 498 } 499 return sql.substr(0, sql.length-5); 500 } 501 502 503 /** 504 * Get the ids associated with a particular transaction set 505 **/ 506 function _getSetData(transactionSetID){ 507 var sql = "SELECT event, id FROM transactionSets WHERE transactionSetID=" 508 + transactionSetID; 509 return Zotero.DB.rowQuery(sql); 510 } 511 512 513 function _reloadAndNotify(transactionSetID, redo){ 514 var data = _getSetData(transactionSetID); 515 var eventParts = data['event'].split('-'); // e.g. modify-item 516 if (redo){ 517 switch (eventParts[0]){ 518 case 'add': 519 eventParts[0] = 'remove'; 520 break; 521 case 'remove': 522 eventParts[0] = 'add'; 523 break; 524 } 525 } 526 switch (eventParts[1]){ 527 case 'item': 528 Zotero.Items.reload(data['id']); 529 break; 530 } 531 532 Zotero.Notifier.trigger(eventParts[0], eventParts[1], data['id']); 533 } 534 }