[Sugar-devel] [PATCH] Journal Volumes Backup and Restore
Martin Abente
mabente at paraguayeduca.org
Mon Jun 28 15:01:02 EDT 2010
Add a basic backup and restore feature for the Sugar Journal.
It provides:
- Generic Backup and Restore dialog GUI.
- Process manager class as an abstraction layer between the dialog and
backup/restore scripts. (Allowing to work with many backup and restore
technologies, using the same GUI, with no need for script rewrite).
- Basic file system Volume Restore and Backup scripts implemented in Python.
- New backup and restore options for journal volumes palettes.
This patch is based on Esteban Arias (Plan Ceibal) Volume Backup and Restore
patch, with a few changes:
- Refactor original Backup dialog class into a generic dialog class.
- Create specialized VolumeBackupDialog and VolumeRestoreDialog subclasses.
- Rewrite backup and restore scripts in python for an easier sugar interaction.
- Add backup identification helpers to jarabe.journal.misc.
---
bin/Makefile.am | 4 +-
bin/journal-backup-volume | 56 ++++++++
bin/journal-restore-volume | 65 +++++++++
src/jarabe/journal/Makefile.am | 3 +-
src/jarabe/journal/misc.py | 27 ++++
src/jarabe/journal/processdialog.py | 233 +++++++++++++++++++++++++++++++++
src/jarabe/journal/volumestoolbar.py | 5 +-
src/jarabe/model/Makefile.am | 3 +-
src/jarabe/model/processmanagement.py | 98 ++++++++++++++
src/jarabe/view/palettes.py | 44 ++++++
10 files changed, 533 insertions(+), 5 deletions(-)
create mode 100644 bin/journal-backup-volume
create mode 100644 bin/journal-restore-volume
create mode 100644 src/jarabe/journal/processdialog.py
create mode 100644 src/jarabe/model/processmanagement.py
diff --git a/bin/Makefile.am b/bin/Makefile.am
index 05a9215..8cc87b5 100644
--- a/bin/Makefile.am
+++ b/bin/Makefile.am
@@ -5,7 +5,9 @@ python_scripts = \
sugar-install-bundle \
sugar-launch \
sugar-session \
- sugar-ui-check
+ sugar-ui-check \
+ journal-backup-volume \
+ journal-restore-volume
bin_SCRIPTS = \
sugar \
diff --git a/bin/journal-backup-volume b/bin/journal-backup-volume
new file mode 100644
index 0000000..10bdba9
--- /dev/null
+++ b/bin/journal-backup-volume
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+import os
+import sys
+import subprocess
+import logging
+
+from sugar import env
+#from sugar.datastore import datastore
+
+backup_identifier = sys.argv[2]
+volume_path = sys.argv[1]
+
+if len(sys.argv) != 3:
+ print 'Usage: %s <volume_path> <backup_identifier>' % sys.argv[0]
+ exit(1)
+
+logging.debug('Backup started')
+
+backup_path = os.path.join(volume_path, 'backup', backup_identifier)
+
+if not os.path.exists(backup_path):
+ os.makedirs(backup_path)
+
+#datastore.freeze()
+
+result = 0
+try:
+ cmd = ['tar', '-C', env.get_profile_path(), '-czf', \
+ os.path.join(backup_path, 'datastore.tar.gz'), 'datastore']
+
+ subprocess.check_call(cmd)
+
+except Exception, e:
+ logging.error('Backup failed: %s', str(e))
+ result = 1
+
+#datastore.thaw()
+
+logging.debug('Backup finished')
+exit(result)
diff --git a/bin/journal-restore-volume b/bin/journal-restore-volume
new file mode 100644
index 0000000..3150fca
--- /dev/null
+++ b/bin/journal-restore-volume
@@ -0,0 +1,65 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+import os
+import sys
+import shutil
+import logging
+import subprocess
+
+from sugar import env
+#from sugar.datastore import datastore
+
+backup_identifier = sys.argv[2]
+volume_path = sys.argv[1]
+
+if len(sys.argv) != 3:
+ print 'Usage: %s <volume_path> <backup_identifier>' % sys.argv[0]
+ exit(1)
+
+logging.debug('Restore started')
+
+journal_path = os.path.join(env.get_profile_path(), 'datastore')
+backup_path = os.path.join(volume_path, 'backup', backup_identifier, 'datastore.tar.gz')
+
+if not os.path.exists(backup_path):
+ logging.error('Could not find backup file %s', backup_path)
+ exit(1)
+
+#datastore.freeze()
+
+result = 0
+try:
+ if os.path.exists(journal_path):
+ shutil.rmtree(journal_path)
+
+ subprocess.check_call(['tar', '-C', env.get_profile_path(), '-xzf', backup_path])
+
+except Exception, e:
+ logging.error('Restore failed: %s', str(e))
+ result = 1
+
+try:
+ shutil.rmtree(os.path.join(journal_path, 'index'))
+ os.remove(os.path.join(journal_path, 'index_updated'))
+except:
+ logging.debug('Restore has no index files')
+
+#datastore.thaw()
+
+logging.debug('Restore finished')
+exit(result)
diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am
index f4bf273..a760869 100644
--- a/src/jarabe/journal/Makefile.am
+++ b/src/jarabe/journal/Makefile.am
@@ -14,4 +14,5 @@ sugar_PYTHON = \
model.py \
objectchooser.py \
palettes.py \
- volumestoolbar.py
+ volumestoolbar.py \
+ processdialog.py
diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py
index 24ad216..6e3cb95 100644
--- a/src/jarabe/journal/misc.py
+++ b/src/jarabe/journal/misc.py
@@ -1,4 +1,5 @@
# Copyright (C) 2007, One Laptop Per Child
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -249,3 +250,29 @@ def get_icon_color(metadata):
return XoColor(client.get_string('/desktop/sugar/user/color'))
else:
return XoColor(metadata['icon-color'])
+
+def get_backup_identifier():
+ serial_number = get_xo_serial()
+ if serial_number is None:
+ serial_number = get_nick()
+ return serial_number
+
+def get_xo_serial():
+ path = '/ofw/serial-number'
+
+ if os.access(path, os.R_OK) == 0:
+ return None
+
+ file_descriptor = open(path, 'r')
+ content = file_descriptor.read()
+ file_descriptor.close()
+
+ if content:
+ return content.strip()
+ else:
+ logging.error('No serial number at %s', path)
+ return None
+
+def get_nick():
+ client = gconf.client_get_default()
+ return client.get_string("/desktop/sugar/user/nick")
diff --git a/src/jarabe/journal/processdialog.py b/src/jarabe/journal/processdialog.py
new file mode 100644
index 0000000..05bc14b
--- /dev/null
+++ b/src/jarabe/journal/processdialog.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python
+# Copyright (C) 2010, Plan Ceibal <comunidad at plan.ceibal.edu.uy>
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import gtk
+import gobject
+import gconf
+import logging
+
+from gettext import gettext as _
+from sugar.graphics import style
+from sugar.graphics.icon import Icon
+from sugar.graphics.xocolor import XoColor
+
+from jarabe.journal import misc
+from jarabe.model import shell
+from jarabe.model import processmanagement
+from jarabe.model.session import get_session_manager
+
+class ProcessDialog(gtk.Window):
+
+ __gtype_name__ = 'SugarProcessDialog'
+
+ def __init__(self, process_script, process_params):
+
+ #FIXME: Workaround limitations of Sugar core modal handling
+ shell_model = shell.get_model()
+ shell_model.set_zoom_level(shell_model.ZOOM_HOME)
+
+ gtk.Window.__init__(self)
+
+ self._restart_after = False
+ self._process_script = processmanagement.find_and_absolutize(process_script)
+ self._process_params = process_params
+ self._start_message = _('Running')
+ self._failed_message = _('Failed')
+ self._finished_message = _('Finished')
+
+ self.set_border_width(style.LINE_WIDTH)
+ width = gtk.gdk.screen_width()
+ height = gtk.gdk.screen_height()
+ self.set_size_request(width, height)
+ self.set_position(gtk.WIN_POS_CENTER_ALWAYS)
+ self.set_decorated(False)
+ self.set_resizable(False)
+ self.set_modal(True)
+
+ self._colored_box = gtk.EventBox()
+ self._colored_box.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse("white"))
+ self._colored_box.show()
+
+ self._vbox = gtk.VBox()
+ self._vbox.set_spacing(style.DEFAULT_SPACING)
+ self._vbox.set_border_width(style.GRID_CELL_SIZE)
+
+ self._colored_box.add(self._vbox)
+ self.add(self._colored_box)
+
+ self._setup_information()
+ self._setup_progress_bar()
+ self._setup_options()
+
+ self._vbox.show()
+
+ self.connect("realize", self.__realize_cb)
+
+ self._process_management = processmanagement.ProcessManagement()
+ self._process_management.connect('process-management-running', self._set_status_updated)
+ self._process_management.connect('process-management-started', self._set_status_started)
+ self._process_management.connect('process-management-finished', self._set_status_finished)
+ self._process_management.connect('process-management-failed', self._set_status_failed)
+
+ def _setup_information(self):
+ client = gconf.client_get_default()
+ color = XoColor(client.get_string('/desktop/sugar/user/color'))
+
+ self._icon = Icon(icon_name='activity-journal', pixel_size=style.XLARGE_ICON_SIZE, xo_color=color)
+ self._icon.show()
+
+ self._vbox.pack_start(self._icon, False)
+
+ self._title = gtk.Label()
+ self._title.modify_fg(gtk.STATE_NORMAL, style.COLOR_BLACK.get_gdk_color())
+ self._title.set_use_markup(True)
+ self._title.show()
+
+ self._vbox.pack_start(self._title, False)
+
+ self._message = gtk.Label()
+ self._message.modify_fg(gtk.STATE_NORMAL, style.COLOR_BLACK.get_gdk_color())
+ self._message.set_use_markup(True)
+ self._message.set_line_wrap(True)
+ self._message.show()
+
+ self._vbox.pack_start(self._message, True)
+
+ def _setup_options(self):
+ hbox = gtk.HBox(True, 3)
+ hbox.show()
+
+ self._start_button = gtk.Button()
+ self._start_button.set_label(_('Start'))
+ self._start_button.connect('clicked', self.__start_cb)
+ self._start_button.show()
+
+ self._close_button = gtk.Button()
+ self._close_button.set_label(_('Close'))
+ self._close_button.connect('clicked', self.__close_cb)
+ self._close_button.show()
+
+ self._restart_button = gtk.Button()
+ self._restart_button.set_label(_('Restart'))
+ self._restart_button.connect('clicked', self.__restart_cb)
+ self._restart_button.hide()
+
+ hbox.add(self._start_button)
+ hbox.add(self._close_button)
+ hbox.add(self._restart_button)
+
+ halign = gtk.Alignment(1, 0, 0, 0)
+ halign.show()
+ halign.add(hbox)
+
+ self._vbox.pack_start(halign, False, False, 3)
+
+ def _setup_progress_bar(self):
+ alignment = gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5)
+ alignment.show()
+
+ self._progress_bar = gtk.ProgressBar(adjustment=None)
+ self._progress_bar.hide()
+
+ alignment.add(self._progress_bar)
+ self._vbox.pack_start(alignment)
+
+ def __realize_cb(self, widget):
+ self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
+ self.window.set_accept_focus(True)
+
+ def __close_cb(self, button):
+ self.destroy()
+
+ def __start_cb(self, button):
+ self._process_management.do_process([self._process_script] + self._process_params)
+
+ def __restart_cb(self, button):
+ session_manager = get_session_manager()
+ session_manager.logout()
+
+ def _set_status_started(self, model, data=None):
+ self._message.set_text(self._start_message)
+
+ self._start_button.hide()
+ self._close_button.hide()
+
+ self._progress_bar.set_fraction(0.05)
+ self._progress_bar_handler = gobject.timeout_add(1000, self.__progress_bar_handler_cb)
+ self._progress_bar.show()
+
+ def __progress_bar_handler_cb(self):
+ self._progress_bar.pulse()
+ return True
+
+ def _set_status_updated(self, model, data):
+ pass
+
+ def _set_status_finished(self, model, data=None):
+ self._message.set_text(self._finished_message)
+
+ self._progress_bar.hide()
+ self._start_button.hide()
+
+ if self._restart_after:
+ self._restart_button.show()
+ else:
+ self._close_button.show()
+
+ def _set_status_failed(self, model, error_message=''):
+ self._message.set_text('%s %s' % (self._failed_message, error_message))
+
+ self._progress_bar.hide()
+ self._start_button.show()
+ self._close_button.show()
+
+ logging.error(error_message)
+
+
+class VolumeBackupDialog(ProcessDialog):
+
+ def __init__(self, volume_path):
+ process_script = 'journal-backup-volume'
+ process_params = [volume_path, misc.get_backup_identifier()]
+
+ ProcessDialog.__init__(self, process_script, process_params)
+ self._resetup_information(volume_path)
+
+ def _resetup_information(self, volume_path):
+ self._start_message = _('Please wait, saving journal content to %s.') % volume_path
+ self._finished_message = _('The journal content has been saved.')
+
+ self._title.set_markup('<b>%s</b>' % _('Backup'))
+ self._message.set_text(_('Journal content will be saved to %s') % volume_path)
+
+class VolumeRestoreDialog(ProcessDialog):
+
+ def __init__(self, volume_path):
+ process_script = 'journal-restore-volume'
+ process_params = [volume_path, misc.get_backup_identifier()]
+
+ ProcessDialog.__init__(self, process_script, process_params)
+ self._resetup_information(volume_path)
+ self._restart_after = True
+
+ def _resetup_information(self, volume_path):
+ self._start_message = _('Please wait, restoring journal content from %s') % volume_path
+ self._finished_message = _('The journal content has been restored.')
+
+ self._title.set_markup('<b>%s</b>' % _('Restore'))
+ self._message.set_text(_('Journal content will be restored from %s') % volume_path)
+
diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py
index 74b974c..2e64fe2 100644
--- a/src/jarabe/journal/volumestoolbar.py
+++ b/src/jarabe/journal/volumestoolbar.py
@@ -27,7 +27,7 @@ from sugar.graphics.palette import Palette
from sugar.graphics.xocolor import XoColor
from jarabe.journal import model
-from jarabe.view.palettes import VolumePalette
+from jarabe.view.palettes import JournalVolumePalette
class VolumesToolbar(gtk.Toolbar):
__gtype_name__ = 'VolumesToolbar'
@@ -164,11 +164,12 @@ class VolumeButton(BaseButton):
self.props.xo_color = color
def create_palette(self):
- palette = VolumePalette(self._mount)
+ palette = JournalVolumePalette(self._mount)
#palette.props.invoker = FrameWidgetInvoker(self)
#palette.set_group_id('frame')
return palette
+
class JournalButton(BaseButton):
def __init__(self):
BaseButton.__init__(self, mount_point='/')
diff --git a/src/jarabe/model/Makefile.am b/src/jarabe/model/Makefile.am
index e9f0700..8fdc552 100644
--- a/src/jarabe/model/Makefile.am
+++ b/src/jarabe/model/Makefile.am
@@ -15,4 +15,5 @@ sugar_PYTHON = \
shell.py \
screen.py \
session.py \
- sound.py
+ sound.py \
+ processmanagement.py
diff --git a/src/jarabe/model/processmanagement.py b/src/jarabe/model/processmanagement.py
new file mode 100644
index 0000000..466e1f6
--- /dev/null
+++ b/src/jarabe/model/processmanagement.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
+# Copyright (C) 2010, Plan Ceibal <comunidad at plan.ceibal.edu.uy>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import os
+import gobject
+import glib
+import gio
+
+from sugar import env
+from gettext import gettext as _
+
+BYTES_TO_READ = 100
+
+class ProcessManagement(gobject.GObject):
+
+ __gtype_name__ = 'ProcessManagement'
+
+ __gsignals__ = {
+ 'process-management-running' : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([str])),
+ 'process-management-started' : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+ 'process-management-finished' : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])),
+ 'process-management-failed' : (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([str]))
+ }
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self._running = False
+
+ def do_process(self, cmd):
+ self._run_cmd_async(cmd)
+
+ def _report_process_status(self, stream, result):
+ data = stream.read_finish(result)
+
+ if len(data):
+ self.emit('process-management-running', data)
+ stream.read_async(BYTES_TO_READ, self._report_process_status)
+
+ def _report_process_error(self, stream, result, concat_err=''):
+ data = stream.read_finish(result)
+ concat_err = concat_err + data
+
+ if len(data) == 0:
+ self.emit('process-management-failed', concat_err)
+ else:
+ stream.read_async(BYTES_TO_READ, self._report_process_error, user_data=concat_err)
+
+ def _notify_error(self, stderr):
+ stdin_stream = gio.unix.InputStream(stderr, True)
+ stdin_stream.read_async(BYTES_TO_READ, self._report_process_error)
+
+ def _notify_process_status(self, stdout):
+ stdin_stream = gio.unix.InputStream(stdout, True)
+ stdin_stream.read_async(BYTES_TO_READ, self._report_process_status)
+
+ def _run_cmd_async(self, cmd):
+ if self._running == False:
+ try:
+ pid, stdin, stdout, stderr = glib.spawn_async(cmd, flags=glib.SPAWN_DO_NOT_REAP_CHILD, standard_output=True, standard_error=True)
+ gobject.child_watch_add(pid, _handle_process_end, (self, stderr))
+ except Exception:
+ self.emit('process-management-failed', _("Error - Call process: ") + str(cmd))
+ else:
+ self._notify_process_status(stdout)
+ self._running = True
+ self.emit('process-management-started')
+
+def _handle_process_end(pid, condition, (myself, stderr)):
+ myself._running = False
+
+ if os.WIFEXITED(condition) and\
+ os.WEXITSTATUS(condition) == 0:
+ myself.emit('process-management-finished')
+ else:
+ myself._notify_error(stderr)
+
+def find_and_absolutize(script_name):
+ paths = env.os.environ['PATH'].split(':')
+ for path in paths:
+ looking_path = path + '/' + script_name
+ if env.os.path.isfile(looking_path):
+ return looking_path
+
+ return None
diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py
index ad84f08..2fc4d5f 100644
--- a/src/jarabe/view/palettes.py
+++ b/src/jarabe/view/palettes.py
@@ -1,4 +1,6 @@
# Copyright (C) 2008 One Laptop Per Child
+# Copyright (C) 2010, Plan Ceibal <comunidad at plan.ceibal.edu.uy>
+# Copyright (C) 2010, Paraguay Educa <tecnologia at paraguayeduca.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -31,6 +33,7 @@ from sugar.graphics.xocolor import XoColor
from sugar.activity import activityfactory
from sugar.activity.activityhandle import ActivityHandle
+from jarabe.journal.processdialog import VolumeBackupDialog, VolumeRestoreDialog
from jarabe.model import shell
from jarabe.view import launcher
from jarabe.view.viewsource import setup_view_source
@@ -258,3 +261,44 @@ class VolumePalette(Palette):
self._free_space_label.props.label = _('%(free_space)d MB Free') % \
{'free_space': free_space / (1024 * 1024)}
+
+class JournalVolumePalette(VolumePalette):
+
+ __gtype_name__ = 'JournalVolumePalette'
+
+ def __init__(self, mount):
+ VolumePalette.__init__(self, mount)
+
+ journal_separator = gtk.SeparatorMenuItem()
+ journal_separator.show()
+
+ self.menu.prepend(journal_separator)
+
+ icon = Icon(icon_name='transfer-from', icon_size=gtk.ICON_SIZE_MENU)
+ icon.show()
+
+ menu_item_journal_restore = MenuItem(_('Restore Journal'))
+ menu_item_journal_restore.set_image(icon)
+ menu_item_journal_restore.connect('activate', self.__journal_restore_activate_cb, mount.get_root().get_path())
+ menu_item_journal_restore.show()
+
+ self.menu.prepend(menu_item_journal_restore)
+
+ icon = Icon(icon_name='transfer-to', icon_size=gtk.ICON_SIZE_MENU)
+ icon.show()
+
+ menu_item_journal_backup = MenuItem(_('Backup Journal'))
+ menu_item_journal_backup.set_image(icon)
+ menu_item_journal_backup.connect('activate', self.__journal_backup_activate_cb, mount.get_root().get_path())
+ menu_item_journal_backup.show()
+
+ self.menu.prepend(menu_item_journal_backup)
+
+ def __journal_backup_activate_cb(self, menu_item, mount_path):
+ dialog = VolumeBackupDialog(mount_path)
+ dialog.show()
+
+ def __journal_restore_activate_cb(self, menu_item, mount_path):
+ dialog = VolumeRestoreDialog(mount_path)
+ dialog.show()
+
--
1.6.0.4
More information about the Sugar-devel
mailing list