Package node_manager_fkie :: Module launch_list_model
[frames] | no frames]

Source Code for Module node_manager_fkie.launch_list_model

  1  # Software License Agreement (BSD License) 
  2  # 
  3  # Copyright (c) 2012, Fraunhofer FKIE/US, Alexander Tiderko 
  4  # All rights reserved. 
  5  # 
  6  # Redistribution and use in source and binary forms, with or without 
  7  # modification, are permitted provided that the following conditions 
  8  # are met: 
  9  # 
 10  #  * Redistributions of source code must retain the above copyright 
 11  #    notice, this list of conditions and the following disclaimer. 
 12  #  * Redistributions in binary form must reproduce the above 
 13  #    copyright notice, this list of conditions and the following 
 14  #    disclaimer in the documentation and/or other materials provided 
 15  #    with the distribution. 
 16  #  * Neither the name of Fraunhofer nor the names of its 
 17  #    contributors may be used to endorse or promote products derived 
 18  #    from this software without specific prior written permission. 
 19  # 
 20  # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
 21  # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
 22  # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 
 23  # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 
 24  # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 
 25  # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 
 26  # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 
 27  # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 
 28  # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 
 29  # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 
 30  # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
 31  # POSSIBILITY OF SUCH DAMAGE. 
 32   
 33  import os 
 34  import sys 
 35  import shutil 
 36   
 37  from python_qt_binding import QtCore 
 38  from python_qt_binding import QtGui 
 39   
 40  import node_manager_fkie as nm 
 41  from common import is_package, package_name 
 42  from packages_thread import PackagesThread 
 43  from .detailed_msg_box import WarningMessageBox 
44 45 -class LaunchItem(QtGui.QStandardItem):
46 ''' 47 The launch item stored in the launch model. 48 ''' 49 50 ITEM_TYPE = QtGui.QStandardItem.UserType + 40 51 52 NOT_FOUND = -1 53 NOTHING = 0 54 RECENT_FILE = 1 55 LAUNCH_FILE = 2 56 CFG_FILE = 3 57 FOLDER = 10 58 PACKAGE = 11 59 STACK = 12 60
61 - def __init__(self, name, path, id, parent=None):
62 ''' 63 Initialize the topic item. 64 @param name: the topic name 65 @type name: C{str} 66 ''' 67 QtGui.QStandardItem.__init__(self, name) 68 self.parent_item = parent 69 self.name = name 70 self.path = path 71 self.id = id 72 if self.id == LaunchItem.FOLDER: 73 self.setIcon(QtGui.QIcon(":/icons/crystal_clear_folder.png")) 74 elif self.id == LaunchItem.PACKAGE: 75 self.setIcon(QtGui.QIcon(":/icons/crystal_clear_package.png")) 76 elif self.id == LaunchItem.LAUNCH_FILE: 77 self.setIcon(QtGui.QIcon(":/icons/crystal_clear_launch_file.png")) 78 elif self.id == LaunchItem.RECENT_FILE: 79 self.setIcon(QtGui.QIcon(":/icons/crystal_clear_launch_file_recent.png")) 80 elif self.id == LaunchItem.STACK: 81 self.setIcon(QtGui.QIcon(":/icons/crystal_clear_stack.png"))
82 83 # def __del__(self): 84 # print "delete LAUNCH", self.name 85
86 - def type(self):
87 return LaunchItem.ITEM_TYPE
88
89 - def data(self, role):
90 ''' 91 The view asks us for all sorts of information about our data... 92 @param index: parent of the list 93 @type index: L{QtCore.QModelIndex} 94 @param role: the art of the data 95 @type role: L{QtCore.Qt.DisplayRole} 96 @see: U{http://www.pyside.org/docs/pyside-1.0.1/PySide/QtCore/Qt.html} 97 ''' 98 if role == QtCore.Qt.DisplayRole: 99 # return the displayed item name 100 if self.id == LaunchItem.RECENT_FILE: 101 return "%s [%s]"%(self.name, package_name(os.path.dirname(self.path))[0])#.decode(sys.getfilesystemencoding()) 102 else: 103 return self.name 104 elif role == QtCore.Qt.ToolTipRole: 105 # return the tooltip of the item 106 if self.id == LaunchItem.RECENT_FILE: 107 result = "%s\nPress 'Delete' to remove the entry from the history list"%self.path 108 return self.path.decode(sys.getfilesystemencoding()) 109 # elif role == QtCore.Qt.DecorationRole: 110 # # return the showed icon 111 # pathItem, path, pathId = self.items[index.row()] 112 # if self.id > LaunchListModel.NOTHING and self.model().icons.has_key(self.id): 113 # return self.model().icons[self.id] 114 # return None 115 elif role == QtCore.Qt.EditRole: 116 return self.name 117 else: 118 # We don't care about anything else, so return None 119 # return None 120 return QtGui.QStandardItem.data(self, role)
121
122 - def setData(self, value, role=QtCore.Qt.EditRole):
123 if role == QtCore.Qt.EditRole: 124 # rename the file or folder 125 if self.name != value and self.id in [self.RECENT_FILE, self.LAUNCH_FILE, self.CFG_FILE, self.FOLDER]: 126 new_path = os.path.join(os.path.dirname(self.path), value) 127 if not os.path.exists(new_path): 128 os.rename(self.path, new_path) 129 self.name = value 130 self.path = new_path 131 else: 132 WarningMessageBox(QtGui.QMessageBox.Warning, "Path already exists", 133 "`%s` already exists!"%value, "Complete path: %s"%new_path).exec_() 134 return QtGui.QStandardItem.setData(self, value, role)
135 136 @classmethod
137 - def getItemList(self, name, path, id, root):
138 ''' 139 Creates the list of the items . This list is used for the 140 visualization of topic data as a table row. 141 @param name: the topic name 142 @type name: C{str} 143 @param root: The parent QStandardItem 144 @type root: L{PySide.QtGui.QStandardItem} 145 @return: the list for the representation as a row 146 @rtype: C{[L{LaunchItem} or L{PySide.QtGui.QStandardItem}, ...]} 147 ''' 148 items = [] 149 item = LaunchItem(name, path, id, parent=root) 150 items.append(item) 151 return items
152
153 - def isLaunchFile(self):
154 ''' 155 @return: C{True} if it is a launch file 156 @rtype: C{boolean} 157 ''' 158 return not self.path is None and os.path.isfile(self.path) and self.path.endswith('.launch')
159
160 - def isConfigFile(self):
161 ''' 162 @return: C{True} if it is a config file 163 @rtype: C{boolean} 164 ''' 165 return self.id == self.CFG_FILE
166
167 168 169 -class LaunchListModel(QtGui.QStandardItemModel):
170 ''' 171 The model to manage the list with launch files. 172 ''' 173 header = [('Name', -1)] 174 '''@ivar: the list with columns C{[(name, width), ...]}''' 175
176 - def __init__(self):
177 ''' 178 Creates a new list model. 179 ''' 180 QtGui.QStandardItemModel.__init__(self) 181 self.setColumnCount(len(LaunchListModel.header)) 182 self.setHorizontalHeaderLabels([label for label, width in LaunchListModel.header]) 183 self.pyqt_workaround = dict() # workaround for using with PyQt: store the python object to keep the defined attributes in the TopicItem subclass 184 self.items = [] 185 self.DIR_CACHE = {} 186 self.currentPath = None 187 self.load_history = self._getLoadHistory() 188 self.root_paths = [os.path.normpath(p) for p in os.getenv("ROS_PACKAGE_PATH").split(':')] 189 self._setNewList(self._moveUp(None)) 190 self.__packages = {} 191 self._fill_packages_thread = PackagesThread() 192 self._fill_packages_thread.packages.connect(self._fill_packages) 193 self._fill_packages_thread.start()
194
195 - def _getRootItems(self):
196 result = list(self.load_history) 197 result.extend(self.root_paths) 198 return result
199
200 - def _fill_packages(self, packages):
201 self.__packages = packages
202 203 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 204 #%%%%%%%%%%%%% Overloaded methods %%%%%%%% 205 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 206
207 - def flags(self, index):
208 ''' 209 @param index: parent of the list 210 @type index: L{PySide.QtCore.QModelIndex} 211 @return: Flag or the requested item 212 @rtype: L{PySide.QtCore.Qt.ItemFlag} 213 @see: U{http://www.pyside.org/docs/pyside-1.0.1/PySide/QtCore/Qt.html} 214 ''' 215 if not index.isValid(): 216 return QtCore.Qt.NoItemFlags 217 try: 218 item = self.itemFromIndex(index) 219 result = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsDragEnabled 220 if item.id in [LaunchItem.RECENT_FILE, LaunchItem.LAUNCH_FILE, LaunchItem.CFG_FILE, LaunchItem.FOLDER]: 221 result = result | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsDragEnabled 222 return result 223 except: 224 return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
225 226 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 227 #%%%%%%%%%%%%% Drag operation %%%%%%%% 228 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 229
230 - def mimeTypes(self):
231 return ['text/plain']
232
233 - def mimeData(self, indexes):
234 mimeData = QtCore.QMimeData() 235 text = '' 236 for index in indexes: 237 if index.isValid(): 238 item = self.itemFromIndex(index) 239 prev = '%s\n'%text if text else '' 240 text = '%sfile://%s'%(prev, item.path) 241 mimeData.setData('text/plain', text) 242 return mimeData
243 244 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 245 #%%%%%%%%%%%%% External usage %%%%%%%% 246 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 247
248 - def reloadPackages(self):
249 ''' 250 Reloads the cached packag list. 251 ''' 252 if not self._fill_packages_thread.isAlive(): 253 self._fill_packages_thread = PackagesThread() 254 self._fill_packages_thread.packages.connect(self._fill_packages) 255 self._fill_packages_thread.start()
256
257 - def reloadCurrentPath(self):
258 ''' 259 Reloads the current path. 260 ''' 261 # clear the cache for package names 262 try: 263 from common import PACKAGE_CACHE 264 PACKAGE_CACHE.clear() 265 self.DIR_CACHE = {} 266 except: 267 import traceback 268 print traceback.format_exc() 269 try: 270 if self.currentPath is None: 271 self._setNewList(self._moveUp(self.currentPath)) 272 else: 273 self._setNewList(self._moveDown(self.currentPath)) 274 except: 275 self._setNewList(self._moveUp(None))
276
277 - def expandItem(self, path_item, path, id):
278 ''' 279 Returns for the given item and path the file path if this is a file. Otherwise the 280 folder will be expanded and None will be returned. 281 @param path_item: the list item 282 @type path_item: C{str} 283 @param path: the real path of the item 284 @type path: C{str} 285 @return: path of the launch file or None 286 @rtype: C{str 287 @raise Exception if no path to given item was found 288 ''' 289 if path_item == '..': 290 root_path, items = self._moveUp(os.path.dirname(path)) 291 elif os.path.isfile(path): 292 return path 293 elif id == LaunchItem.RECENT_FILE or id == LaunchItem.LAUNCH_FILE: 294 raise Exception("Invalid file path: %s", path) 295 else: 296 root_path, items = self._moveDown(path) 297 self._setNewList((root_path, items)) 298 return None
299 300
301 - def setPath(self, path):
302 ''' 303 Shows the new path in the launch configuration view. Only if the new path 304 is in ros package paths 305 @param path: new path 306 @type path: C{str} 307 ''' 308 # if self._is_in_ros_packages(path): 309 self._setNewList(self._moveDown(path))
310
311 - def add2LoadHistory(self, file):
312 try: 313 self.load_history.remove(file) 314 except: 315 pass 316 self.load_history.append(file) 317 try: 318 while len(self.load_history) > nm.settings().launch_history_length: 319 self.load_history.pop(0) 320 except: 321 pass 322 self._storeLoadHistory(self.load_history)
323 # self.reloadCurrentPath() # todo: keep the item selected in list view after the reload the path 324
325 - def removeFromLoadHistory(self, file):
326 try: 327 self.load_history.remove(file) 328 except: 329 pass 330 self._storeLoadHistory(self.load_history)
331 # self.reloadCurrentPath() # todo: keep the item selected in list view after the reload the path 332
333 - def show_packages(self, show):
334 if show: 335 # clear the cache for package names 336 try: 337 items = [] 338 for package, path in self.__packages.items(): 339 items.append((package, path, LaunchItem.PACKAGE)) 340 self._setNewList((self.currentPath if self.currentPath else '', items)) 341 except: 342 import traceback 343 print traceback.format_exc() 344 else: 345 self._setNewList(self._moveUp(self.currentPath))
346
347 - def paste_from_clipboard(self):
348 ''' 349 Copy the file or folder to new position... 350 ''' 351 if QtGui.QApplication.clipboard().mimeData().hasText() and self.currentPath: 352 text = QtGui.QApplication.clipboard().mimeData().text() 353 if text.startswith('file://'): 354 path = text.replace('file://', '') 355 basename = os.path.basename(text) 356 ok = True 357 if os.path.exists(os.path.join(self.currentPath, basename)): 358 basename, ok = QtGui.QInputDialog.getText(None, 'File exists', 'New name (or override):', QtGui.QLineEdit.Normal, basename) 359 if ok and basename: 360 if os.path.isdir(path): 361 shutil.copytree(path, os.path.join(self.currentPath, basename)) 362 elif os.path.isfile(path): 363 shutil.copy2(path, os.path.join(self.currentPath, basename)) 364 self.reloadCurrentPath()
365
366 - def copy_to_clipboard(self, indexes):
367 ''' 368 Copy the selected path to the clipboard 369 ''' 370 mimeData = QtCore.QMimeData() 371 text = '' 372 for index in indexes: 373 if index.isValid(): 374 item = self.itemFromIndex(index) 375 prev = '%s\n'%text if text else '' 376 text = '%sfile://%s'%(prev, item.path) 377 mimeData.setData('text/plain', text) 378 QtGui.QApplication.clipboard().setMimeData(mimeData)
379 380 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 381 #%%%%%%%%%%%%% Functionality %%%%%%%% 382 #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% 383
384 - def _setNewList(self, (root_path, items)):
385 ''' 386 Sets the list to the given path and insert the items. If the root path is not 387 None the additional item '..' to go up will be inserted. The items parameter 388 is a tupel with three values (the displayed name, the path of the item, the id 389 of the item). 390 @see: L{LaunchListModel._addPathToList()} 391 @param root_path: the root directory 392 @type root_path: C{str} 393 @param items: the list with characterized items 394 @type items: C{[(item, path, id)]} 395 ''' 396 root = self.invisibleRootItem() 397 while root.rowCount(): 398 root.removeRow(0) 399 self.pyqt_workaround.clear() 400 # add new items 401 if not root_path is None: 402 self._addPathToList('..', root_path, LaunchItem.NOTHING) 403 for item_name, item_path, item_id in items: 404 self._addPathToList(item_name, item_path, item_id) 405 self.currentPath = root_path
406
407 - def _is_in_ros_packages(self, path):
408 ''' 409 Test whether the given path is in ROS_PACKAGE_PATH. 410 @return: C{True}, if the path is in the ROS_PACKAGE_PATH 411 @rtype: C{boolean} 412 ''' 413 #TODO fix for paths with symbolic links 414 for p in self.root_paths: 415 if path.startswith(p): 416 return True 417 return False
418
419 - def _addPathToList(self, item, path, path_id):
420 ''' 421 Inserts the given item in the list model. 422 @param item: the displayed name 423 @type item: C{str} 424 @param path: the path of the item 425 @type path: C{str} 426 @param path_id: the id of the item, which represents whether it is a file, package or stack. 427 @type path_id: C{constants of LaunchListModel} 428 ''' 429 root = self.invisibleRootItem() 430 if item is None or path is None or path_id == LaunchItem.NOT_FOUND: 431 return False 432 if (path_id != LaunchItem.NOT_FOUND): 433 # add sorted a new entry 434 try: 435 for i in range(root.rowCount()): 436 launchItem = root.child(i) 437 launch_file_cmp = (path_id in [LaunchItem.RECENT_FILE, LaunchItem.LAUNCH_FILE] and item < launchItem.name) 438 launch_id_cmp = (launchItem.id > path_id and launchItem.id > LaunchItem.LAUNCH_FILE) 439 launch_name_cmp = (launchItem.id == path_id and item < launchItem.name) 440 if launch_file_cmp or launch_id_cmp or launch_name_cmp: 441 new_item_row = LaunchItem.getItemList(item, path, path_id, root) 442 root.insertRow(i, new_item_row) 443 self.pyqt_workaround[item] = new_item_row[0] # workaround for using with PyQt: store the python object to keep the defined attributes in the TopicItem subclass 444 return True 445 new_item_row = LaunchItem.getItemList(item, path, path_id, root) 446 root.appendRow(new_item_row) 447 self.pyqt_workaround[item] = new_item_row[0] 448 return True 449 except: 450 import traceback 451 print traceback.format_exc() 452 return False
453
454 - def _identifyPath(self, path):
455 ''' 456 Determines the id of the given path 457 @return: the id represents whether it is a file, package or stack 458 @rtype: C{constants of LaunchItem} 459 ''' 460 if path in self.DIR_CACHE: 461 if path in self.load_history: 462 return LaunchItem.RECENT_FILE 463 return self.DIR_CACHE[path] 464 if os.path.basename(path)[0] != '.': 465 if path in self.load_history: 466 self.DIR_CACHE[path] = LaunchItem.RECENT_FILE 467 return LaunchItem.RECENT_FILE 468 elif os.path.isfile(path): 469 if (path.endswith('.launch')): 470 self.DIR_CACHE[path] = LaunchItem.LAUNCH_FILE 471 return LaunchItem.LAUNCH_FILE 472 else: 473 for e in nm.Settings().launch_view_file_ext: 474 if path.endswith(e): 475 self.DIR_CACHE[path] = LaunchItem.CFG_FILE 476 return LaunchItem.CFG_FILE 477 elif os.path.isdir(path): 478 fileList = os.listdir(path) 479 if self._containsLaunches(path): 480 if 'stack.xml' in fileList: 481 self.DIR_CACHE[path] = LaunchItem.STACK 482 return LaunchItem.STACK 483 elif is_package(fileList): 484 self.DIR_CACHE[path] = LaunchItem.PACKAGE 485 return LaunchItem.PACKAGE 486 else: 487 self.DIR_CACHE[path] = LaunchItem.FOLDER 488 return LaunchItem.FOLDER 489 self.DIR_CACHE[path] = LaunchItem.NOT_FOUND 490 return LaunchItem.NOT_FOUND
491
492 - def _containsLaunches(self, path):
493 ''' 494 Moves recursively down in the path tree and searches for a launch file. If 495 one is found True will be returned. 496 @return: C{True} if the path contains a launch file. 497 @rtype: C{boolean} 498 ''' 499 fileList = os.listdir(path) 500 for file in fileList: 501 file_name, file_extension = os.path.splitext(file) 502 if os.path.isfile(os.path.join(path, file)) and (file.endswith('.launch')) or (file_extension in nm.Settings().launch_view_file_ext): 503 return True 504 for file in fileList: 505 if os.path.isdir(os.path.join(path, file)): 506 if self._containsLaunches(os.path.join(path, file)): 507 return True 508 return False
509
510 - def _moveDown(self, path):
511 ''' 512 Moves recursively down in the path tree until the current path contains a 513 launch file. 514 @return: tupel of (root_path, items) 515 @rtype: C{tupel of (root_path, items)} 516 @see: L{LaunchListModel._setNewList()} 517 ''' 518 result_list = [] 519 dirlist = os.listdir(path) 520 for file in dirlist: 521 item = os.path.normpath(''.join([path, '/', file])) 522 pathItem = os.path.basename(item) 523 if pathItem == 'src': 524 pathItem = '%s (src)'%os.path.basename(os.path.dirname(item)) 525 pathId = self._identifyPath(item) 526 if (pathId != LaunchItem.NOT_FOUND): 527 result_list.append((pathItem, item, pathId)) 528 if len(result_list) == 1 and not os.path.isfile(result_list[0][1]): 529 return self._moveDown(result_list[0][1]) 530 return path, result_list
531
532 - def _moveUp(self, path):
533 ''' 534 Moves recursively up in the path tree until the current path contains a 535 launch file or the root path defined by ROS_PACKAGES_PATH is reached. 536 @return: tupel of (root_path, items) 537 @rtype: C{tupel of (root_path, items)} 538 @see: L{LaunchListModel._setNewList()} 539 ''' 540 result_list = [] 541 if path is None or not self._is_in_ros_packages(path): 542 dirlist = self._getRootItems() 543 path = None 544 else: 545 dirlist = os.listdir(path) 546 for file in dirlist: 547 item = os.path.normpath(os.path.join(path, file)) if not path is None else file 548 pathItem = os.path.basename(item) 549 if pathItem == 'src': 550 pathItem = '%s (src)'%os.path.basename(os.path.dirname(item)) 551 pathId = self._identifyPath(item) 552 if (pathId != LaunchItem.NOT_FOUND): 553 result_list.append((pathItem, item, pathId)) 554 if not path is None and len(result_list) == 1 and not os.path.isfile(result_list[0][1]): 555 return self._moveUp(os.path.dirname(path)) 556 else: 557 self.currentPath = None 558 return path, result_list
559
560 - def _getLoadHistory(self):
561 ''' 562 Read the history of the recently loaded files from the file stored in ROS_HOME path. 563 @return: the list with file names 564 @rtype: C{[str]} 565 ''' 566 result = list() 567 historyFile = nm.settings().qsettings(nm.settings().LAUNCH_HISTORY_FILE) 568 size = historyFile.beginReadArray("launch_history") 569 for i in range(size): 570 historyFile.setArrayIndex(i) 571 if i >= nm.settings().launch_history_length: 572 break 573 file = historyFile.value("file") 574 if os.path.isfile(file): 575 result.append(file) 576 historyFile.endArray() 577 return result
578
579 - def _storeLoadHistory(self, files):
580 ''' 581 Saves the list of recently loaded files to history. The existing history will be replaced! 582 @param files: the list with filenames 583 @type files: C{[str]} 584 ''' 585 historyFile = nm.settings().qsettings(nm.settings().LAUNCH_HISTORY_FILE) 586 historyFile.beginWriteArray("launch_history") 587 for i, file in enumerate(files): 588 historyFile.setArrayIndex(i) 589 historyFile.setValue("file", file) 590 historyFile.endArray()
591