1<?php 2 3declare(strict_types=1); 4 5namespace MStilkerich\CardDavAddressbook4Roundcube; 6 7use rcmail; 8use rcube_db; 9use Psr\Log\LoggerInterface; 10 11/** 12 * Access module for the roundcube database. 13 * 14 * The purpose of this class is to decouple all access to the roundcube database from the rest of the plugin. The main 15 * purpose of this is to set a ground for testing, where the actual access to the database (this class) could be 16 * replaced by mocks. The methods of this class should be able to satisfy all query needs of the plugin without the need 17 * to have SQL queries directly inside the plugin, as these would be difficult to parse in a test mock. 18 * 19 * @todo At the moment, this class is just a container for the already existing methods and only partially fulfills its 20 * purpose stated above. 21 */ 22abstract class Database 23{ 24 /** @var string[] DBTABLES_WITHOUT_ID List of table names that have no single ID column. */ 25 private const DBTABLES_WITHOUT_ID = ['group_user']; 26 27 /** @var LoggerInterface $logger */ 28 private static $logger; 29 30 /** @var ?rcube_db $dbHandle */ 31 private static $dbHandle; 32 33 /** @var bool $inTransaction Indicates whether we are currently inside a transaction */ 34 private static $inTransaction; 35 36 /** 37 * Initializes the Database class. 38 * 39 * Must be called before using any methods in this class. 40 * 41 * @param LoggerInterface $logger A logger object that log messages can be sent to. 42 */ 43 public static function init(LoggerInterface $logger, rcube_db $dbh = null): void 44 { 45 self::$logger = $logger; 46 47 self::$inTransaction = false; 48 49 if (isset($dbh)) { 50 self::setDbHandle($dbh); 51 } 52 } 53 54 public static function getDbHandle(): rcube_db 55 { 56 $dbh = self::$dbHandle; 57 if (!isset($dbh)) { 58 $dbh = rcmail::get_instance()->db; 59 self::setDbHandle($dbh); 60 } 61 62 return $dbh; 63 } 64 65 /** 66 * Starts a transaction on the internal DB connection. 67 * 68 * Note that all queries in the transaction must be done using the functions provided by this class, or the database 69 * handle acquired by the getDbHandle() function of this class, to make sure they use the same database connection. 70 * 71 * @see self::getDbHandle() 72 */ 73 public static function startTransaction(bool $readonly = true): void 74 { 75 $dbh = self::getDbHandle(); 76 77 if (self::$inTransaction) { 78 throw new \Exception("Cannot start nested transaction"); 79 } else { 80 // SQLite3 always has Serializable isolation of transactions, and does not support 81 // the SET TRANSACTION command. 82 $level = $readonly ? 'REPEATABLE READ' : 'SERIALIZABLE'; 83 $mode = $readonly ? "READ ONLY" : "READ WRITE"; 84 85 switch ($dbh->db_provider) { 86 case "mysql": 87 $ret = $dbh->query("SET TRANSACTION ISOLATION LEVEL $level, $mode"); 88 break; 89 case "sqlite": 90 $ret = true; 91 break; 92 case "postgres": 93 $ret = $dbh->query("SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL $level, $mode"); 94 break; 95 default: 96 self::$logger->critical("Unsupported database backend: " . $dbh->db_provider); 97 return; 98 } 99 100 if ($ret !== false) { 101 $ret = $dbh->startTransaction(); 102 } 103 104 if ($ret === false) { 105 self::$logger->error(__METHOD__ . " ERROR: " . $dbh->is_error()); 106 throw new DatabaseException($dbh->is_error()); 107 } 108 109 self::$inTransaction = true; 110 } 111 } 112 113 /** 114 * Commits the transaction on the internal DB connection. 115 */ 116 public static function endTransaction(): void 117 { 118 $dbh = self::getDbHandle(); 119 120 if (self::$inTransaction) { 121 self::$inTransaction = false; 122 123 if ($dbh->endTransaction() === false) { 124 self::$logger->error("Database::endTransaction ERROR: " . $dbh->is_error()); 125 throw new DatabaseException($dbh->is_error()); 126 } 127 128 self::resetTransactionSettings(); 129 } else { 130 throw new \Exception("Attempt to commit a transaction while not within a transaction"); 131 } 132 } 133 134 /** 135 * Rolls back the transaction on the internal DB connection. 136 */ 137 public static function rollbackTransaction(): void 138 { 139 $dbh = self::getDbHandle(); 140 141 if (self::$inTransaction) { 142 self::$inTransaction = false; 143 if ($dbh->rollbackTransaction() === false) { 144 self::$logger->error("Database::rollbackTransaction ERROR: " . $dbh->is_error()); 145 throw new \Exception($dbh->is_error()); 146 } 147 148 self::resetTransactionSettings(); 149 } else { 150 // not throwing an error here facilitates usage of the interface at caller side. The caller 151 // can issue rollback without having to keep track whether an error occurred before/after a 152 // transaction was started/ended. 153 self::$logger->notice("Ignored request to rollback a transaction while not within a transaction"); 154 } 155 } 156 157 /** 158 * Resets database transaction settings to defaults that should apply to autocommit transactions. 159 */ 160 private static function resetTransactionSettings(): void 161 { 162 $logger = self::$logger; 163 $dbh = self::getDbHandle(); 164 165 switch ($dbh->db_provider) { 166 case "postgres": 167 $ret = $dbh->query( 168 "SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE" 169 ); 170 break; 171 default: 172 return; 173 } 174 175 // reset default session characteristics to read/write for autocommit single-statement transactions 176 if ($ret === false) { 177 $logger->warning(__METHOD__ . " ERROR: " . $dbh->is_error()); 178 } 179 } 180 181 /** 182 * Checks if the database schema is up to date and performs migrations if needed. 183 * 184 * If this function encounters an error, it will abort execution of the migrations. The database will be 185 * in a potentially broken state, causing further errors when the plugin is executed. Unfortunately, I do not see a 186 * better way to handle errors. Throwing an exception would result in Roundcube not being usable at all for the user 187 * in case of errors. 188 * 189 * @param string $dbPrefix The optional prefix to all database table names as configured in Roundcube. 190 * @param string $scriptDir Path of the parent directory containing all the migration scripts, each in a subdir. 191 */ 192 public static function checkMigrations(string $dbPrefix, string $scriptDir): void 193 { 194 $dbh = self::getDbHandle(); 195 196 // We only support the non-commercial database types supported by roundcube, so quit with an error 197 switch ($dbh->db_provider) { 198 case "mysql": 199 $db_backend = "mysql"; 200 break; 201 case "sqlite": 202 $db_backend = "sqlite3"; 203 break; 204 case "postgres": 205 $db_backend = "postgres"; 206 break; 207 default: 208 self::$logger->critical("Unsupported database backend: " . $dbh->db_provider); 209 return; 210 } 211 212 // (1) Determine which migration scripts are available, in alphabetical ascending order 213 $migrationsAvailable = []; 214 foreach (scandir($scriptDir, SCANDIR_SORT_ASCENDING) as $migrationDir) { 215 if (preg_match("/^\d{4}-/", $migrationDir)) { 216 $migrationsAvailable[] = $migrationDir; 217 } 218 } 219 220 // (2) Determine which migration scripts have already been executed. This must handle the initial case that 221 // the plugin's database tables to not exist yet, in which case they will be initialized. 222 $migrationsDone = []; 223 $dbh->set_option('ignore_key_errors', true); 224 $sql_result = $dbh->query('SELECT filename FROM ' . $dbh->table_name('carddav_migrations')) ?: []; 225 while ($processed = $dbh->fetch_assoc($sql_result)) { 226 $migrationsDone[$processed['filename']] = true; 227 } 228 $dbh->set_option('ignore_key_errors', null); 229 230 // (3) Execute the migration scripts that have not been executed before 231 foreach ($migrationsAvailable as $migration) { 232 // skip migrations that have already been done 233 if (key_exists($migration, $migrationsDone)) { 234 continue; 235 } 236 237 self::$logger->notice("In migration: $migration"); 238 239 $phpMigrationScript = "$scriptDir/$migration/migrate.php"; 240 $sqlMigrationScript = "$scriptDir/$migration/$db_backend.sql"; 241 242 if (file_exists($phpMigrationScript)) { 243 include $phpMigrationScript; 244 $migrationClass = "\MStilkerich\CardDavAddressbook4Roundcube\DBMigrations\Migration" 245 . substr($migration, 0, 4); // the 4-digit number 246 247 /** 248 * @psalm-suppress InvalidStringClass 249 * @var DBMigrationInterface $migrationObj 250 */ 251 $migrationObj = new $migrationClass(); 252 if ($migrationObj->migrate($dbh, self::$logger) === false) { 253 return; // error already logged 254 } 255 } elseif (file_exists($sqlMigrationScript)) { 256 if (self::performSqlMigration($sqlMigrationScript, $dbPrefix, $dbh) === false) { 257 return; // error already logged 258 } 259 } else { 260 self::$logger->warning("No migration script found for: $migration"); 261 // do not continue with other scripts that may depend on this one 262 return; 263 } 264 265 $dbh->query( 266 "INSERT INTO " . $dbh->table_name("carddav_migrations") . " (filename) VALUES (?)", 267 $migration 268 ); 269 270 if ($dbh->is_error()) { 271 self::$logger->error("Recording exec of migration $migration failed: " . $dbh->is_error()); 272 return; 273 } 274 } 275 } 276 277 private static function performSqlMigration(string $migrationScript, string $dbPrefix, rcube_db $dbh): bool 278 { 279 $queries_raw = file_get_contents($migrationScript); 280 281 if ($queries_raw === false) { 282 self::$logger->error("Failed to read migration script: $migrationScript - aborting"); 283 return false; 284 } 285 286 $queryCount = preg_match_all('/.+?;/s', $queries_raw, $queries); 287 self::$logger->info("Found $queryCount queries in $migrationScript"); 288 if ($queryCount > 0) { 289 foreach ($queries[0] as $query) { 290 $query = str_replace("TABLE_PREFIX", $dbPrefix, $query); 291 $dbh->query($query); 292 293 if ($dbh->is_error()) { 294 self::$logger->error("Migration query ($query) failed: " . $dbh->is_error()); 295 return false; 296 } 297 } 298 } 299 300 return true; 301 } 302 303 /** 304 * Stores a contact to the local database. 305 * 306 * @param string etag of the VCard in the given version on the CardDAV server 307 * @param string path to the VCard on the CardDAV server 308 * @param string string representation of the VCard 309 * @param array associative array containing the roundcube save data for the contact 310 * @param ?string optionally, database id of the contact if the store operation is an update 311 * 312 * @return string The database id of the created or updated card. 313 */ 314 public static function storeContact( 315 string $abookid, 316 string $etag, 317 string $uri, 318 string $vcfstr, 319 array $save_data, 320 ?string $dbid = null 321 ) { 322 // build email search string 323 $email_keys = preg_grep('/^email(:|$)/', array_keys($save_data)); 324 $email_addrs = []; 325 foreach ($email_keys as $email_key) { 326 $email_addrs = array_merge($email_addrs, (array) $save_data[$email_key]); 327 } 328 $save_data['email'] = implode(', ', $email_addrs); 329 330 // extra columns for the contacts table 331 $xcol_all = array('firstname','surname','organization','showas','email'); 332 $xcol = []; 333 $xval = []; 334 foreach ($xcol_all as $k) { 335 if (key_exists($k, $save_data)) { 336 $xcol[] = $k; 337 $xval[] = $save_data[$k]; 338 } 339 } 340 341 return self::storeAddressObject('contacts', $abookid, $etag, $uri, $vcfstr, $save_data, $dbid, $xcol, $xval); 342 } 343 344 /** 345 * Stores a group in the database. 346 * 347 * If the group is based on a KIND=group vcard, the record must be stored with ETag, URI and VCard. Otherwise, if 348 * the group is derived from a CATEGORIES property of a contact VCard, the ETag, URI and VCard must be set to NULL 349 * to indicate this. 350 * 351 * @param array associative array containing at least name and cuid (card UID) 352 * @param ?string optionally, database id of the group if the store operation is an update 353 * @param ?string etag of the VCard in the given version on the CardDAV server 354 * @param ?string path to the VCard on the CardDAV server 355 * @param ?string string representation of the VCard 356 * 357 * @return string The database id of the created or updated card. 358 */ 359 public static function storeGroup( 360 string $abookid, 361 array $save_data, 362 ?string $dbid = null, 363 ?string $etag = null, 364 ?string $uri = null, 365 ?string $vcfstr = null 366 ) { 367 return self::storeAddressObject('groups', $abookid, $etag, $uri, $vcfstr, $save_data, $dbid); 368 } 369 370 /** 371 * Inserts a new contact or group into the database, or updates an existing one. 372 * 373 * If the address object is not backed by an object on the server side (CATEGORIES-type groups), the parameters 374 * $etag, $uri and $vcfstr are not applicable and shall be passed as NULL. 375 * 376 * @param string $table The target table, without carddav_ prefix (contacts or groups) 377 * @param string $abookid The database ID of the addressbook the address object belongs to. 378 * @param ?string $etag The ETag value of the CardDAV-server address object that this object is created from. 379 * @param ?string $uri The URI of the CardDAV-server address object that this object is created from. 380 * @param ?string $vcfstr The VCard string of the CardDAV-server address object that this object is created from. 381 * @param string[] $save_data The Roundcube representation of the address object. 382 * @param ?string $dbid If an existing object is updated, this specifies its database id. 383 * @param string[] $xcol Database column names of attributes to insert. 384 * @param string[] $xval The values to insert into the column specified by $xcol at the corresponding index. 385 * @return string The database id of the created or updated card. 386 */ 387 private static function storeAddressObject( 388 string $table, 389 string $abookid, 390 ?string $etag, 391 ?string $uri, 392 ?string $vcfstr, 393 array $save_data, 394 ?string $dbid, 395 array $xcol = [], 396 array $xval = [] 397 ): string { 398 $dbh = self::getDbHandle(); 399 400 $carddesc = $uri ?? "(entry not backed by card)"; 401 $xcol[] = 'name'; 402 $xval[] = $save_data['name']; 403 404 if (isset($etag)) { 405 $xcol[] = 'etag'; 406 $xval[] = $etag; 407 } 408 409 if (isset($vcfstr)) { 410 $xcol[] = 'vcard'; 411 $xval[] = $vcfstr; 412 } 413 414 if (isset($dbid)) { 415 self::$logger->debug("UPDATE card $dbid/$carddesc in $table"); 416 417 self::update($dbid, $xcol, $xval, $table, 'id'); 418 } else { 419 self::$logger->debug("INSERT card $carddesc to $table"); 420 421 $xcol[] = 'abook_id'; 422 $xval[] = $abookid; 423 424 if (isset($uri)) { 425 $xcol[] = 'uri'; 426 $xval[] = $uri; 427 } 428 if (isset($save_data['cuid'])) { 429 $xcol[] = 'cuid'; 430 $xval[] = $save_data['cuid']; 431 } 432 433 $dbid = self::insert($table, $xcol, $xval); 434 } 435 436 437 return $dbid; 438 } 439 440 /** 441 * Stores a new entity to the database. 442 * 443 * @param string $table The database table to store the entity to. 444 * @param string[] $cols Database column names of attributes to insert. 445 * @param string[] $vals The values to insert into the column specified by $cols at the corresponding index. 446 * @return string The database id of the created database record. Empty string if the table has no ID column. 447 */ 448 public static function insert(string $table, array $cols, array $vals): string 449 { 450 $dbh = self::getDbHandle(); 451 452 $sql = 'INSERT INTO ' . $dbh->table_name("carddav_$table") . 453 '(' . implode(",", $cols) . ') ' . 454 'VALUES (?' . str_repeat(',?', count($cols) - 1) . ')'; 455 456 $sql_result = $dbh->query($sql, $vals); 457 458 if (in_array($table, self::DBTABLES_WITHOUT_ID)) { 459 $dbid = ""; 460 } else { 461 $dbid = $dbh->insert_id("carddav_$table"); 462 $dbid = is_bool($dbid) ? "" /* error thrown below */ : (string) $dbid; 463 } 464 self::$logger->debug("INSERT $table ($sql) -> $dbid"); 465 466 if ($dbh->is_error()) { 467 self::$logger->error("Database::insert ($sql) ERROR: " . $dbh->is_error()); 468 throw new \Exception($dbh->is_error()); 469 } 470 471 return $dbid; 472 } 473 474 /** 475 * Updates records in a database table. 476 * 477 * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be 478 * updated. These IDs are queried against the database column specified by $idfield. Can 479 * be a single or multiple values, null is not permitted. 480 * @param string[] $cols Database column names of attributes to update. 481 * @param string[] $vals The values to set into the column specified by $cols at the corresponding index. 482 * @param string $table Name of the database table to select from, without the carddav_ prefix. 483 * @param string $idfield The name of the column against which $id is matched. 484 * @param array $other_conditions An associative array with database column names as keys and their match criterion 485 * as value. 486 * @return int The number of rows updated. 487 * @see self::getConditionQuery() 488 */ 489 public static function update( 490 $id, 491 array $cols, 492 array $vals, 493 string $table = 'contacts', 494 string $idfield = 'id', 495 array $other_conditions = [] 496 ): int { 497 $dbh = self::getDbHandle(); 498 $sql = 'UPDATE ' . $dbh->table_name("carddav_$table") . ' SET ' . implode("=?,", $cols) . '=? WHERE '; 499 500 // Main selection condition 501 $sql .= self::getConditionQuery($dbh, $idfield, $id); 502 503 // Append additional conditions 504 $sql .= self::getOtherConditionsQuery($dbh, $other_conditions); 505 506 self::$logger->debug("UPDATE $table ($sql)"); 507 $sql_result = $dbh->query($sql, $vals); 508 509 if ($dbh->is_error()) { 510 self::$logger->error("Database::update ($sql) ERROR: " . $dbh->is_error()); 511 throw new DatabaseException($dbh->is_error()); 512 } 513 514 return $dbh->affected_rows($sql_result); 515 } 516 517 /** 518 * Gets rows from a database table. 519 * 520 * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be 521 * queried. These IDs are queried against the database column specified by $idfield. Can 522 * be a single or multiple values, null is not permitted. 523 * @param string $cols A comma-separated list of database column names used in the SELECT clause of the SQL 524 * statement. By default, all columns are selected. 525 * @param string $table Name of the database table to select from, without the carddav_ prefix. 526 * @param bool $retsingle If true, exactly one single row is expected as result. If false, any number of rows is 527 * expected as result. 528 * @param string $idfield The name of the column against which $id is matched. 529 * @param array $other_conditions An associative array with database column names as keys and their match criterion 530 * as value. 531 * @return array If $retsingle is true and no error occurred, returns an associative row array with the 532 * matching row, where keys are fieldnames and their value is the corresponding database 533 * value of the field in the result row. If $retsingle is false, a possibly empty array 534 * of such row-arrays is returned. 535 * @see self::getConditionQuery() 536 */ 537 public static function get( 538 $id, 539 string $cols = '*', 540 string $table = 'contacts', 541 bool $retsingle = true, 542 string $idfield = 'id', 543 array $other_conditions = [] 544 ): array { 545 $dbh = self::getDbHandle(); 546 547 $sql = "SELECT $cols FROM " . $dbh->table_name("carddav_$table") . ' WHERE '; 548 549 // Main selection condition 550 $sql .= self::getConditionQuery($dbh, $idfield, $id); 551 552 // Append additional conditions 553 $sql .= self::getOtherConditionsQuery($dbh, $other_conditions); 554 555 $sql_result = $dbh->query($sql); 556 557 if ($dbh->is_error()) { 558 self::$logger->error("Database::get ($sql) ERROR: " . $dbh->is_error()); 559 throw new DatabaseException($dbh->is_error()); 560 } 561 562 // single result row expected? 563 if ($retsingle) { 564 $ret = $dbh->fetch_assoc($sql_result); 565 if ($ret === false) { 566 throw new \Exception("Single-row query ($sql) without result"); 567 } 568 return $ret; 569 } else { 570 // multiple rows requested 571 $ret = []; 572 while ($row = $dbh->fetch_assoc($sql_result)) { 573 $ret[] = $row; 574 } 575 return $ret; 576 } 577 } 578 579 /** 580 * Deletes rows from a database table. 581 * 582 * @param string|string[] $id A single database ID (string) or an array of database IDs if several records should be 583 * deleted. These IDs are queried against the database column specified by $idfield. Can 584 * be a single or multiple values, null is not permitted. 585 * @param string $table Name of the database table to select from, without the carddav_ prefix. 586 * @param string $idfield The name of the column against which $id is matched. 587 * @param array $other_conditions An associative array with database column names as keys and their match criterion 588 * as value. 589 * @return int The number of rows deleted. 590 * @see self::getConditionQuery() 591 */ 592 public static function delete( 593 $id, 594 string $table = 'contacts', 595 string $idfield = 'id', 596 array $other_conditions = [] 597 ): int { 598 $dbh = self::getDbHandle(); 599 600 $sql = "DELETE FROM " . $dbh->table_name("carddav_$table") . " WHERE "; 601 602 // Main selection condition 603 $sql .= self::getConditionQuery($dbh, $idfield, $id); 604 605 // Append additional conditions 606 $sql .= self::getOtherConditionsQuery($dbh, $other_conditions); 607 608 self::$logger->debug("Database::delete $sql"); 609 610 $sql_result = $dbh->query($sql); 611 612 if ($dbh->is_error()) { 613 self::$logger->error("Database::delete ($sql) ERROR: " . $dbh->is_error()); 614 throw new DatabaseException($dbh->is_error()); 615 } 616 617 return $dbh->affected_rows($sql_result); 618 } 619 620 /** 621 * Creates a condition query on a database column to be used in an SQL WHERE clause. 622 * 623 * @param rcube_db $dbh The roundcube database handle. 624 * @param string $field Name of the database column. 625 * Prefix with ! to invert the condition. 626 * Prefix with % to indicate that the value is a pattern to be matched with ILIKE. 627 * Prefixes can be combined but must be given in the order listed here. 628 * @param ?string|string[] $value The value to check field for. Can be one of the following: 629 * - null: Assert that the field is NULL (or not NULL if inverted) 630 * - string: Assert that the field value matches $value (or does not match value, if inverted) 631 * - string[]: Assert that the field matches one of the strings in values (or none, if inverted) 632 */ 633 private static function getConditionQuery(rcube_db $dbh, string $field, $value): string 634 { 635 $invertCondition = false; 636 $ilike = false; 637 638 if ($field[0] === "!") { 639 $field = substr($field, 1); 640 $invertCondition = true; 641 } 642 if ($field[0] === "%") { 643 $field = substr($field, 1); 644 $ilike = true; 645 } 646 647 $sql = $dbh->quote_identifier($field); 648 if (!isset($value)) { 649 $sql .= $invertCondition ? ' IS NOT NULL' : ' IS NULL'; 650 } elseif (is_array($value)) { 651 if (count($value) > 0) { 652 if ($ilike) { 653 throw new \Exception("getConditionQuery $field - ILIKE match only supported for single pattern"); 654 } 655 $quoted_values = array_map([ $dbh, 'quote' ], $value); 656 $sql .= $invertCondition ? " NOT IN" : " IN"; 657 $sql .= " (" . implode(",", $quoted_values) . ")"; 658 } else { 659 throw new \Exception("getConditionQuery $field - empty values array provided"); 660 } 661 } else { 662 if ($ilike) { 663 if ($dbh->db_provider === "mysql") { 664 $sql .= " COLLATE utf8mb4_unicode_ci "; 665 } 666 $ilikecmd = ($dbh->db_provider === "postgres") ? "ILIKE" : "LIKE"; 667 $sql .= $invertCondition ? " NOT $ilikecmd " : " $ilikecmd "; 668 } else { 669 $sql .= $invertCondition ? " <> " : " = "; 670 } 671 $sql .= $dbh->quote($value); 672 } 673 674 return $sql; 675 } 676 677 private static function getOtherConditionsQuery(rcube_db $dbh, array $other_conditions): string 678 { 679 $sql = ""; 680 681 foreach ($other_conditions as $field => $value) { 682 $sql .= ' AND '; 683 $sql .= self::getConditionQuery($dbh, $field, $value); 684 } 685 686 return $sql; 687 } 688 689 private static function setDbHandle(rcube_db $dbh): void 690 { 691 self::$dbHandle = $dbh; 692 693 // attempt to enable foreign key constraints on SQLite. May fail if not supported 694 if ($dbh->db_provider == "sqlite") { 695 $dbh->query('PRAGMA FOREIGN_KEYS=ON;'); 696 } 697 } 698} 699 700// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120 701