alacarte.in: Fix indentation
[alacarte.git] / Alacarte / MenuEditor.py
blob1d39f954f1b1069f46c67130e0a0de82b4f5286a
1 # -*- coding: utf-8 -*-
2 # Alacarte Menu Editor - Simple fd.o Compliant Menu Editor
3 # Copyright (C) 2006 Travis Watkins, Heinrich Wendel
5 # This library is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU Library General Public
7 # License as published by the Free Software Foundation; either
8 # version 2 of the License, or (at your option) any later version.
10 # This library is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13 # Library General Public License for more details.
15 # You should have received a copy of the GNU Library General Public
16 # License along with this library; if not, write to the Free Software
17 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 import codecs
20 import os
21 import xml.dom.minidom
22 import xml.parsers.expat
23 from gi.repository import GMenu, GLib
24 from Alacarte import util
26 class MenuEditor(object):
27 def __init__(self, name=os.environ.get('XDG_MENU_PREFIX', '') + 'applications.menu'):
28 self.name = name
30 self.tree = GMenu.Tree.new(name, GMenu.TreeFlags.SHOW_EMPTY|GMenu.TreeFlags.INCLUDE_EXCLUDED|GMenu.TreeFlags.INCLUDE_NODISPLAY|GMenu.TreeFlags.SHOW_ALL_SEPARATORS|GMenu.TreeFlags.SORT_DISPLAY_NAME)
31 self.tree.connect('changed', self.menuChanged)
32 self.load()
34 self.path = os.path.join(util.getUserMenuPath(), self.tree.props.menu_basename)
35 self.loadDOM()
37 def loadDOM(self):
38 try:
39 self.dom = xml.dom.minidom.parse(self.path)
40 except (IOError, xml.parsers.expat.ExpatError), e:
41 self.dom = xml.dom.minidom.parseString(util.getUserMenuXml(self.tree))
42 util.removeWhitespaceNodes(self.dom)
44 def load(self):
45 if not self.tree.load_sync():
46 raise ValueError("can not load menu tree %r" % (self.name,))
48 def menuChanged(self, *a):
49 self.load()
51 def save(self):
52 with codecs.open(self.path, 'w', 'utf8') as f:
53 f.write(self.dom.toprettyxml())
55 def restoreToSystem(self):
56 self.restoreTree(self.tree.get_root_directory())
57 path = os.path.join(util.getUserMenuPath(), os.path.basename(self.tree.get_canonical_menu_path()))
58 try:
59 os.unlink(path)
60 except OSError:
61 pass
63 self.loadDOM()
64 self.save()
66 def restoreTree(self, menu):
67 item_iter = menu.iter()
68 item_type = item_iter.next()
69 while item_type != GMenu.TreeItemType.INVALID:
70 if item_type == GMenu.TreeItemType.DIRECTORY:
71 item = item_iter.get_directory()
72 self.restoreTree(item)
73 elif item_type == GMenu.TreeItemType.ENTRY:
74 item = item_iter.get_entry()
75 self.restoreItem(item)
76 item_type = item_iter.next()
77 self.restoreMenu(menu)
79 def restoreItem(self, item):
80 if not self.canRevert(item):
81 return
82 try:
83 os.remove(item.get_desktop_file_path())
84 except OSError:
85 pass
86 self.save()
88 def restoreMenu(self, menu):
89 if not self.canRevert(menu):
90 return
91 #wtf happened here? oh well, just bail
92 if not menu.get_desktop_file_path():
93 return
94 file_id = os.path.split(menu.get_desktop_file_path())[1]
95 path = os.path.join(util.getUserDirectoryPath(), file_id)
96 try:
97 os.remove(path)
98 except OSError:
99 pass
100 self.save()
102 def getMenus(self, parent):
103 if parent is None:
104 yield (self.tree.get_root_directory(), True)
105 return
107 item_iter = parent.iter()
108 item_type = item_iter.next()
109 while item_type != GMenu.TreeItemType.INVALID:
110 if item_type == GMenu.TreeItemType.DIRECTORY:
111 item = item_iter.get_directory()
112 yield (item, self.isVisible(item))
113 item_type = item_iter.next()
115 def getContents(self, item):
116 contents = []
117 item_iter = item.iter()
118 item_type = item_iter.next()
120 while item_type != GMenu.TreeItemType.INVALID:
121 item = None
122 if item_type == GMenu.TreeItemType.DIRECTORY:
123 item = item_iter.get_directory()
124 elif item_type == GMenu.TreeItemType.ENTRY:
125 item = item_iter.get_entry()
126 elif item_type == GMenu.TreeItemType.HEADER:
127 item = item_iter.get_header()
128 elif item_type == GMenu.TreeItemType.ALIAS:
129 item = item_iter.get_alias()
130 elif item_type == GMenu.TreeItemType.SEPARATOR:
131 item = item_iter.get_separator()
132 if item:
133 contents.append(item)
134 item_type = item_iter.next()
135 return contents
137 def getItems(self, menu):
138 item_iter = menu.iter()
139 item_type = item_iter.next()
140 while item_type != GMenu.TreeItemType.INVALID:
141 item = None
142 if item_type == GMenu.TreeItemType.ENTRY:
143 item = item_iter.get_entry()
144 elif item_type == GMenu.TreeItemType.DIRECTORY:
145 item = item_iter.get_directory()
146 elif item_type == GMenu.TreeItemType.HEADER:
147 item = item_iter.get_header()
148 elif item_type == GMenu.TreeItemType.ALIAS:
149 item = item_iter.get_alias()
150 elif item_type == GMenu.TreeItemType.SEPARATOR:
151 item = item_iter.get_separator()
152 yield (item, self.isVisible(item))
153 item_type = item_iter.next()
155 def canRevert(self, item):
156 if isinstance(item, GMenu.TreeEntry):
157 if util.getItemPath(item.get_desktop_file_id()) is not None:
158 path = util.getUserItemPath()
159 if os.path.isfile(os.path.join(path, item.get_desktop_file_id())):
160 return True
161 elif isinstance(item, GMenu.TreeDirectory):
162 if item.get_desktop_file_path():
163 file_id = os.path.split(item.get_desktop_file_path())[1]
164 else:
165 file_id = item.get_menu_id() + '.directory'
166 if util.getDirectoryPath(file_id) is not None:
167 path = util.getUserDirectoryPath()
168 if os.path.isfile(os.path.join(path, file_id)):
169 return True
170 return False
172 def setVisible(self, item, visible):
173 dom = self.dom
174 if isinstance(item, GMenu.TreeEntry):
175 menu_xml = self.getXmlMenu(self.getPath(item.get_parent()), dom.documentElement, dom)
176 if visible:
177 self.addXmlFilename(menu_xml, dom, item.get_desktop_file_id(), 'Include')
178 self.writeItem(item, NoDisplay=False)
179 else:
180 self.addXmlFilename(menu_xml, dom, item.get_desktop_file_id(), 'Exclude')
181 self.addXmlTextElement(menu_xml, 'AppDir', util.getUserItemPath(), dom)
182 elif isinstance(item, GMenu.TreeDirectory):
183 item_iter = item.iter()
184 first_child_type = item_iter.next()
185 #don't mess with it if it's empty
186 if first_child_type == GMenu.TreeItemType.INVALID:
187 return
188 menu_xml = self.getXmlMenu(self.getPath(item), dom.documentElement, dom)
189 for node in self.getXmlNodesByName(['Deleted', 'NotDeleted'], menu_xml):
190 node.parentNode.removeChild(node)
191 self.writeMenu(item, NoDisplay=not visible)
192 self.addXmlTextElement(menu_xml, 'DirectoryDir', util.getUserDirectoryPath(), dom)
193 self.save()
195 def createItem(self, parent, before, after, **kwargs):
196 file_id = self.writeItem(None, **kwargs)
197 self.insertExternalItem(file_id, parent.get_menu_id(), before, after)
199 def insertExternalItem(self, file_id, parent_id, before=None, after=None):
200 parent = self.findMenu(parent_id)
201 dom = self.dom
202 self.addItem(parent, file_id, dom)
203 self.positionItem(parent, ('Item', file_id), before, after)
204 self.save()
206 def insertExternalMenu(self, file_id, parent_id, before=None, after=None):
207 menu_id = file_id.rsplit('.', 1)[0]
208 parent = self.findMenu(parent_id)
209 dom = self.dom
210 self.addXmlDefaultLayout(self.getXmlMenu(self.getPath(parent), dom.documentElement, dom) , dom)
211 menu_xml = self.getXmlMenu(self.getPath(parent) + [menu_id], dom.documentElement, dom)
212 self.addXmlTextElement(menu_xml, 'Directory', file_id, dom)
213 self.positionItem(parent, ('Menu', menu_id), before, after)
214 self.save()
216 def createSeparator(self, parent, before=None, after=None):
217 self.positionItem(parent, ('Separator',), before, after)
218 self.save()
220 def editItem(self, item, icon, name, comment, command, use_term, parent=None, final=True):
221 #if nothing changed don't make a user copy
222 app_info = item.get_app_info()
223 if icon == app_info.get_icon() and name == app_info.get_display_name() and comment == item.get_comment() and command == item.get_exec() and use_term == item.get_launch_in_terminal():
224 return
225 #hack, item.get_parent() seems to fail a lot
226 if not parent:
227 parent = item.get_parent()
228 self.writeItem(item, Icon=icon, Name=name, Comment=comment, Exec=command, Terminal=use_term)
229 if final:
230 dom = self.dom
231 menu_xml = self.getXmlMenu(self.getPath(parent), dom.documentElement, dom)
232 self.addXmlTextElement(menu_xml, 'AppDir', util.getUserItemPath(), dom)
233 self.save()
235 def editMenu(self, menu, icon, name, comment, final=True):
236 #if nothing changed don't make a user copy
237 if icon == menu.get_icon() and name == menu.get_name() and comment == menu.get_comment():
238 return
239 #we don't use this, we just need to make sure the <Menu> exists
240 #otherwise changes won't show up
241 dom = self.dom
242 menu_xml = self.getXmlMenu(self.getPath(menu), dom.documentElement, dom)
243 self.writeMenu(menu, Icon=icon, Name=name, Comment=comment)
244 if final:
245 self.addXmlTextElement(menu_xml, 'DirectoryDir', util.getUserDirectoryPath(), dom)
246 self.save()
248 def copyItem(self, item, new_parent, before=None, after=None):
249 dom = self.dom
250 file_path = item.get_desktop_file_path()
251 keyfile = GLib.KeyFile()
252 keyfile.load_from_file(file_path, util.KEY_FILE_FLAGS)
254 util.fillKeyFile(keyfile, dict(Categories=[], Hidden=False))
256 app_info = item.get_app_info()
257 file_id = util.getUniqueFileId(app_info.get_name().replace(os.sep, '-'), '.desktop')
258 out_path = os.path.join(util.getUserItemPath(), file_id)
260 contents, length = keyfile.to_data()
262 with codecs.open(out_path, 'w', 'utf8') as f:
263 f.write(contents)
265 self.addItem(new_parent, file_id, dom)
266 self.positionItem(new_parent, ('Item', file_id), before, after)
267 self.save()
268 return file_id
270 def deleteItem(self, item):
271 self.writeItem(item, Hidden=True)
272 self.save()
274 def deleteMenu(self, menu):
275 dom = self.dom
276 menu_xml = self.getXmlMenu(self.getPath(menu), dom.documentElement, dom)
277 self.addDeleted(menu_xml, dom)
278 self.save()
280 def deleteSeparator(self, item):
281 parent = item.get_parent()
282 contents = self.getContents(parent)
283 contents.remove(item)
284 layout = self.createLayout(contents)
285 dom = self.dom
286 menu_xml = self.getXmlMenu(self.getPath(parent), dom.documentElement, dom)
287 self.addXmlLayout(menu_xml, layout, dom)
288 self.save()
290 def findMenu(self, menu_id, parent=None):
291 if parent is None:
292 parent = self.tree.get_root_directory()
294 if menu_id == parent.get_menu_id():
295 return parent
297 item_iter = parent.iter()
298 item_type = item_iter.next()
299 while item_type != GMenu.TreeItemType.INVALID:
300 if item_type == GMenu.TreeItemType.DIRECTORY:
301 item = item_iter.get_directory()
302 if item.get_menu_id() == menu_id:
303 return item
304 menu = self.findMenu(menu_id, item)
305 if menu is not None:
306 return menu
307 item_type = item_iter.next()
309 def isVisible(self, item):
310 if isinstance(item, GMenu.TreeEntry):
311 app_info = item.get_app_info()
312 return not (item.get_is_excluded() or app_info.get_nodisplay())
313 elif isinstance(item, GMenu.TreeDirectory):
314 return not item.get_is_nodisplay()
315 return True
317 def getPath(self, menu):
318 names = []
319 current = menu
320 while current is not None:
321 names.append(current.get_menu_id())
322 current = current.get_parent()
324 # XXX - don't append root menu name, alacarte doesn't
325 # expect it. look into this more.
326 names.pop(-1)
327 return names[::-1]
329 def getXmlMenuPart(self, element, name):
330 for node in self.getXmlNodesByName('Menu', element):
331 for child in self.getXmlNodesByName('Name', node):
332 if child.childNodes[0].nodeValue == name:
333 return node
334 return None
336 def getXmlMenu(self, path, element, dom):
337 for name in path:
338 found = self.getXmlMenuPart(element, name)
339 if found is not None:
340 element = found
341 else:
342 element = self.addXmlMenuElement(element, name, dom)
343 return element
345 def addXmlMenuElement(self, element, name, dom):
346 node = dom.createElement('Menu')
347 self.addXmlTextElement(node, 'Name', name, dom)
348 return element.appendChild(node)
350 def addXmlTextElement(self, element, name, text, dom):
351 for temp in element.childNodes:
352 if temp.nodeName == name:
353 if temp.childNodes[0].nodeValue == text:
354 return
355 node = dom.createElement(name)
356 text = dom.createTextNode(text)
357 node.appendChild(text)
358 return element.appendChild(node)
360 def addXmlFilename(self, element, dom, filename, type = 'Include'):
361 # remove old filenames
362 for node in self.getXmlNodesByName(['Include', 'Exclude'], element):
363 if node.childNodes[0].nodeName == 'Filename' and node.childNodes[0].childNodes[0].nodeValue == filename:
364 element.removeChild(node)
366 # add new filename
367 node = dom.createElement(type)
368 node.appendChild(self.addXmlTextElement(node, 'Filename', filename, dom))
369 return element.appendChild(node)
371 def addDeleted(self, element, dom):
372 node = dom.createElement('Deleted')
373 return element.appendChild(node)
375 def makeKeyFile(self, file_path, kwargs):
376 if 'KeyFile' in kwargs:
377 return kwargs['KeyFile']
379 keyfile = GLib.KeyFile()
381 if file_path is not None:
382 keyfile.load_from_file(file_path, util.KEY_FILE_FLAGS)
384 util.fillKeyFile(keyfile, kwargs)
385 return keyfile
387 def writeItem(self, item, **kwargs):
388 if item is not None:
389 file_path = item.get_desktop_file_path()
390 else:
391 file_path = None
393 keyfile = self.makeKeyFile(file_path, kwargs)
395 if item is not None:
396 file_id = item.get_desktop_file_id()
397 else:
398 file_id = util.getUniqueFileId(keyfile.get_string(GLib.KEY_FILE_DESKTOP_GROUP, 'Name'), '.desktop')
400 contents, length = keyfile.to_data()
402 path = os.path.join(util.getUserItemPath(), file_id)
403 with codecs.open(path, 'w', 'utf8') as f:
404 f.write(contents)
406 return file_id
408 def writeMenu(self, menu, **kwargs):
409 if menu is not None:
410 file_id = os.path.split(menu.get_desktop_file_path())[1]
411 file_path = menu.get_desktop_file_path()
412 keyfile = GLib.KeyFile()
413 keyfile.load_from_file(file_path, util.KEY_FILE_FLAGS)
414 elif menu is None and 'Name' not in kwargs:
415 raise Exception('New menus need a name')
416 else:
417 file_id = util.getUniqueFileId(kwargs['Name'], '.directory')
418 keyfile = GLib.KeyFile()
420 util.fillKeyFile(keyfile, kwargs)
422 contents, length = keyfile.to_data()
424 path = os.path.join(util.getUserDirectoryPath(), file_id)
425 with codecs.open(path, 'w', 'utf8') as f:
426 f.write(contents)
427 return file_id
429 def getXmlNodesByName(self, name, element):
430 for child in element.childNodes:
431 if child.nodeType == xml.dom.Node.ELEMENT_NODE:
432 if isinstance(name, str) and child.nodeName == name:
433 yield child
434 elif isinstance(name, list) or isinstance(name, tuple):
435 if child.nodeName in name:
436 yield child
438 def addXmlMove(self, element, old, new, dom):
439 if not self.undoMoves(element, old, new, dom):
440 node = dom.createElement('Move')
441 node.appendChild(self.addXmlTextElement(node, 'Old', old, dom))
442 node.appendChild(self.addXmlTextElement(node, 'New', new, dom))
443 #are parsed in reverse order, need to put at the beginning
444 return element.insertBefore(node, element.firstChild)
446 def addXmlLayout(self, element, layout, dom):
447 # remove old layout
448 for node in self.getXmlNodesByName('Layout', element):
449 element.removeChild(node)
451 # add new layout
452 node = dom.createElement('Layout')
453 for order in layout:
454 if order[0] == 'Separator':
455 child = dom.createElement('Separator')
456 node.appendChild(child)
457 elif order[0] == 'Filename':
458 child = self.addXmlTextElement(node, 'Filename', order[1], dom)
459 elif order[0] == 'Menuname':
460 child = self.addXmlTextElement(node, 'Menuname', order[1], dom)
461 elif order[0] == 'Merge':
462 child = dom.createElement('Merge')
463 child.setAttribute('type', order[1])
464 node.appendChild(child)
465 return element.appendChild(node)
467 def addXmlDefaultLayout(self, element, dom):
468 # remove old default layout
469 for node in self.getXmlNodesByName('DefaultLayout', element):
470 element.removeChild(node)
472 # add new layout
473 node = dom.createElement('DefaultLayout')
474 node.setAttribute('inline', 'false')
475 return element.appendChild(node)
477 def createLayout(self, items):
478 layout = []
479 layout.append(('Merge', 'menus'))
480 for item in items:
481 if isinstance(item, GMenu.TreeDirectory):
482 layout.append(('Menuname', item.get_menu_id()))
483 elif isinstance(item, GMenu.TreeEntry):
484 layout.append(('Filename', item.get_desktop_file_id()))
485 elif isinstance(item, GMenu.TreeSeparator):
486 layout.append(('Separator',))
487 else:
488 layout.append(item)
489 layout.append(('Merge', 'files'))
490 return layout
492 def addItem(self, parent, file_id, dom):
493 xml_parent = self.getXmlMenu(self.getPath(parent), dom.documentElement, dom)
494 self.addXmlFilename(xml_parent, dom, file_id, 'Include')
496 def moveItem(self, parent, item, before=None, after=None):
497 self.positionItem(parent, item, before=before, after=after)
498 self.save()
500 def positionItem(self, parent, item, before=None, after=None):
501 contents = self.getContents(parent)
502 if after:
503 index = contents.index(after) + 1
504 elif before:
505 index = contents.index(before)
506 else:
507 # append the item to the list
508 index = len(contents)
509 #if this is a move to a new parent you can't remove the item
510 if item in contents:
511 # decrease the destination index, if we shorten the list
512 if (before and (contents.index(item) < index)) \
513 or (after and (contents.index(item) < index - 1)):
514 index -= 1
515 contents.remove(item)
516 contents.insert(index, item)
517 layout = self.createLayout(contents)
518 dom = self.dom
519 menu_xml = self.getXmlMenu(self.getPath(parent), dom.documentElement, dom)
520 self.addXmlLayout(menu_xml, layout, dom)
522 def undoMoves(self, element, old, new, dom):
523 nodes = []
524 matches = []
525 original_old = old
526 final_old = old
527 #get all <Move> elements
528 for node in self.getXmlNodesByName(['Move'], element):
529 nodes.insert(0, node)
530 #if the <New> matches our old parent we've found a stage to undo
531 for node in nodes:
532 xml_old = node.getElementsByTagName('Old')[0]
533 xml_new = node.getElementsByTagName('New')[0]
534 if xml_new.childNodes[0].nodeValue == old:
535 matches.append(node)
536 #we should end up with this path when completed
537 final_old = xml_old.childNodes[0].nodeValue
538 #undoing <Move>s
539 for node in matches:
540 element.removeChild(node)
541 if len(matches) > 0:
542 for node in nodes:
543 xml_old = node.getElementsByTagName('Old')[0]
544 xml_new = node.getElementsByTagName('New')[0]
545 path = os.path.split(xml_new.childNodes[0].nodeValue)
546 if path[0] == original_old:
547 element.removeChild(node)
548 for node in dom.getElementsByTagName('Menu'):
549 name_node = node.getElementsByTagName('Name')[0]
550 name = name_node.childNodes[0].nodeValue
551 if name == os.path.split(new)[1]:
552 #copy app and dir directory info from old <Menu>
553 root_path = dom.getElementsByTagName('Menu')[0].getElementsByTagName('Name')[0].childNodes[0].nodeValue
554 xml_menu = self.getXmlMenu(root_path + '/' + new, dom.documentElement, dom)
555 for app_dir in node.getElementsByTagName('AppDir'):
556 xml_menu.appendChild(app_dir)
557 for dir_dir in node.getElementsByTagName('DirectoryDir'):
558 xml_menu.appendChild(dir_dir)
559 parent = node.parentNode
560 parent.removeChild(node)
561 node = dom.createElement('Move')
562 node.appendChild(self.addXmlTextElement(node, 'Old', xml_old.childNodes[0].nodeValue, dom))
563 node.appendChild(self.addXmlTextElement(node, 'New', os.path.join(new, path[1]), dom))
564 element.appendChild(node)
565 if final_old == new:
566 return True
567 node = dom.createElement('Move')
568 node.appendChild(self.addXmlTextElement(node, 'Old', final_old, dom))
569 node.appendChild(self.addXmlTextElement(node, 'New', new, dom))
570 return element.appendChild(node)